java-topology/docs/tickets/gorm-0001-sortcallbacks-getindex-quadratic.md
russell@unturf.com d4ed2dff91 ORM wave: 24 defects patched across 10 ORMs (157 sites, 62 ecosystems)
Hibernate (5 HIGH): addColumn/addReferencedColumn/addIndex ArrayList→LinkedHashSet (19x)
  FK second-pass LinkedHashSet, orderHierarchy LinkedHashSet
MyBatis (1 MEDIUM): sortConstructorMappings indexOf→HashMap (12x)
EF Core (2 HIGH + 1 MEDIUM): FindGenerationProperty HashSet (250x),
  AddPrincipals HashSet (250x), FK discovery HashSet (6x)
Diesel (3 MEDIUM): SQLite/MySQL row position()→BTreeMap (51x)
SQLAlchemy (2 HIGH): _values_bindparam Set (500x), evaluated_keys Set (500x)
Peewee (1 MEDIUM): _SortedFieldList.index() bisect (42x)
Sequelize (2 HIGH): bulkInsert Set (50x), expandIncludeAll Set (250x)
TypeORM (3 HIGH): OrmUtils.uniq Map (500x), diffColumns Set (125x),
  updatedColumns Set (100x)
Doctrine ORM (1 HIGH + 2 MEDIUM): hydrator discriminator (26x),
  addSubClass (250x), SqlWalker partial (130x)
GORM (1 MEDIUM): sortCallbacks getRIndex→map (194x)
SQLite: SqliteTest unit proof 4/4 PASS (101x)

Unit tests: all PASS — Hibernate/MyBatis/EfCore/Diesel/SQLAlchemy/Peewee/
  Sequelize/TypeORM/Doctrine/GORM
Whitepaper: 157 sites, 62 ecosystems; PDF 752K
2026-03-27 13:34:26 -04:00

3.2 KiB
Raw Permalink Blame History

gorm-0001 — sortCallbacks: O(n²) getRIndex linear scans during callback registration

Status: OPEN Severity: MEDIUM Component: go-gorm/gormcallbacks.go Affects: Application startup; every call to Register(), Remove(), or Replace()


Root Cause

callbacks.go:252getRIndex():

// DEFECTIVE — O(n) linear scan of string slice
func getRIndex(strs []string, str string) int {
    for i := len(strs) - 1; i >= 0; i-- {
        if strs[i] == str {
            return i
        }
    }
    return -1
}

getRIndex is called 13 times inside sortCallbacks() (lines 278, 287, 291, 297, 305, 309, 315, 335, 349) against names []string and sorted []string, both of length equal to the total callback count N.

sortCallbacks() is called on every Register(), Remove(), and Replace() invocation (callbacks.go:231, 239, 248). Each call re-sorts the full callback list from scratch. For N callbacks, each registration triggers O(N) getRIndex calls, each O(N) → O(N²) total per registration, O(N³) across all N registrations (amortized O(N²) for the full startup sequence).

GORM registers ~26 default callbacks at init time across 6 processors (create, query, update, delete, row, raw).


Complexity Analysis

Metric Defective Fixed
getRIndex(names, name) O(N) O(1) with map[string]int
getRIndex(sorted, name) O(N) O(1) with map[string]int
Full sortCallbacks(N) O(N²) O(N)
All N registrations combined O(N³) O(N²)

For N=26 default callbacks: ~8,788 comparisons vs ~676 with map-based index. The absolute count is small, but each additional Register() call (plugin registration, per-test setup) re-runs the full O(N²) sort. Long-lived GORM applications with many plugins accumulate registration cost.


Fix

Replace names []string and sorted []string with map[string]int for O(1) index lookup:

// FIXED — use maps for O(1) index lookup
func sortCallbacks(cs []*callback) (fns []func(*DB), err error) {
    namesMap := map[string]int{}   // name -> last index in cs
    sortedMap := map[string]int{}  // name -> index in sorted
    sorted := []string{}
    // ...
    // Replace: getRIndex(names, c.name)  ->  namesMap[c.name] (+ existence check)
    // Replace: getRIndex(sorted, c.name) ->  sortedMap[c.name] (+ existence check)
}

The getRIndex function returns the right-most index (for replace/remove semantics). The map can track the most-recently-appended index; update on each names = append(names, c.name).


Call Sites

File Lines Context
callbacks.go 252258 getRIndex definition
callbacks.go 276349 sortCallbacks — outer loop + inner sortCallback
callbacks.go 231, 239, 248 Register, Remove, Replace — trigger on every call

Speedup Estimate

For N=26 default callbacks: modest absolute savings (startup only). For applications with many plugin registrations or test suites that reinitialize GORM (recreate DB with callbacks): O(N)× improvement per sortCallbacks invocation. The primary value is eliminating quadratic scaling as N grows with plugin count.