From ed94b3b56b9fd1380c002c467f3e401355e859be Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:48:26 +0300 Subject: [PATCH 1/2] perf(erd): route search state in flat generation-stamped arrays (#1168) The grid search kept cost, predecessor, start and closed state in hash maps and sets, one entry per expanded state, boxed on every access. The state now lives in typed arrays sized once per grid, with a generation stamp per search, so nothing is cleared between the relations of a route. The search order and the result are unchanged; a 100-table schema test checks every relation routes. --- lib/features/erd/erd_router.dart | 45 ++++++++++++++++++++++---------- test/features/erd/erd_test.dart | 25 ++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/lib/features/erd/erd_router.dart b/lib/features/erd/erd_router.dart index 0b56702d..b749b637 100644 --- a/lib/features/erd/erd_router.dart +++ b/lib/features/erd/erd_router.dart @@ -231,6 +231,15 @@ class _Grid { final List xs, ys; final List rects; late final Uint8List _node; + + // Search state, one entry per (cell, direction). Sized lazily on first search. + late final int _states = xs.length * ys.length * 4; + late final Float64List _cost = Float64List(_states); + late final Int32List _prev = Int32List(_states); + late final Int32List _startOf = Int32List(_states); + late final Int32List _stamp = Int32List(_states); + late final Int32List _closed = Int32List(_states); + int _generation = 0; late final Uint8List _hSeg; late final Uint8List _vSeg; @@ -322,16 +331,24 @@ class _Grid { return best; } - final cost = {}; - final prev = {}; - final startOf = {}; + // Per-state data lives in flat arrays shared by every search of this grid. + // An entry is valid only when its stamp is the current search's generation, + // so nothing is cleared between searches. + final gen = ++_generation; + double costOf(int k) => _stamp[k] == gen ? _cost[k] : double.infinity; + void setCost(int k, double c) { + _stamp[k] = gen; + _cost[k] = c; + } + final heap = _Heap(); for (var s = 0; s < starts.length; s++) { final i = _ix(starts[s].$1.dx), j = _iy(starts[s].$1.dy); if (i < 0 || j < 0 || _blockedNode(i, j)) continue; final k = key(i, j, starts[s].$2); - cost[k] = 0; - startOf[k] = s; + setCost(k, 0); + _prev[k] = -1; + _startOf[k] = s; heap.push(k, heuristic(i, j)); } @@ -340,14 +357,14 @@ class _Grid { int? bestKey; var bestGoal = -1; var expanded = 0; - final closed = {}; while (heap.isNotEmpty) { if (heap.peekPriority >= bestCost) break; final k = heap.pop(); - if (!closed.add(k)) continue; + if (_closed[k] == gen) continue; + _closed[k] = gen; if (++expanded > w * h * 4) break; final d = k % 4, cell = k ~/ 4, i = cell ~/ h, j = cell % h; - final c = cost[k]!; + final c = _cost[k]; final goal = goalAt[cell]; if (goal != null) { final want = goal.$2; @@ -374,9 +391,9 @@ class _Grid { if (_blockedSegment(i, j, nd)) continue; final nc = c + (a - b).distance + (nd == d ? 0 : bendCost); final nk = key(ni, nj, nd); - if (nc < (cost[nk] ?? double.infinity)) { - cost[nk] = nc; - prev[nk] = k; + if (nc < costOf(nk)) { + setCost(nk, nc); + _prev[nk] = k; heap.push(nk, nc + heuristic(ni, nj)); } } @@ -388,11 +405,11 @@ class _Grid { while (true) { final cc = cur ~/ 4; pts.add(Offset(xs[cc ~/ h], ys[cc % h])); - final p = prev[cur]; - if (p == null) break; + final p = _prev[cur]; + if (p < 0) break; cur = p; } - return (pts.reversed.toList(), startOf[cur]!, bestGoal); + return (pts.reversed.toList(), _startOf[cur], bestGoal); } } diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 32a7b46b..9b918d49 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -1225,4 +1225,29 @@ class _LimitRecordingDelegate extends SqlExecutionDelegate { @override bool get supportsTransactions => false; + + group('router on a larger schema (#1168)', () { + test('a 100-table schema routes every relation with a real path', () { + final columns = >[]; + final fks = >[]; + for (var i = 0; i < 100; i++) { + columns.add(['t$i', 'id', 'int', '1', '0']); + columns.add(['t$i', 'parent_id', 'int', '0', '1']); + if (i > 0) { + fks.add(['t$i', 'parent_id', 't${i ~/ 2}', 'id']); + } + if (i > 10 && i % 7 == 0) { + fks.add(['t$i', 'parent_id', 't${i - 9}', 'id']); + } + } + final schema = ErdSchema.fromCatalog(columnRows: columns, fkRows: fks); + final layout = ErdLayout.compute(schema); + final routes = ErdRouter.route(schema, layout); + + expect(routes.length, schema.relations.length); + for (final r in routes) { + expect(r.points.length, greaterThanOrEqualTo(2)); + } + }); + }); } From fbf09fa58ebdd9215b743ed01000fc1421263c2e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:51:39 +0300 Subject: [PATCH 2/2] test(erd): put the larger-schema router test inside main (#1168) --- test/features/erd/erd_test.dart | 50 ++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 9b918d49..3752c10f 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -1195,6 +1195,31 @@ void main() { expect(schema.truncated, isFalse); }); }); + + group('router on a larger schema (#1168)', () { + test('a 100-table schema routes every relation with a real path', () { + final columns = >[]; + final fks = >[]; + for (var i = 0; i < 100; i++) { + columns.add(['t$i', 'id', 'int', '1', '0']); + columns.add(['t$i', 'parent_id', 'int', '0', '1']); + if (i > 0) { + fks.add(['t$i', 'parent_id', 't${i ~/ 2}', 'id']); + } + if (i > 10 && i % 7 == 0) { + fks.add(['t$i', 'parent_id', 't${i - 9}', 'id']); + } + } + final schema = ErdSchema.fromCatalog(columnRows: columns, fkRows: fks); + final layout = ErdLayout.compute(schema); + final routes = ErdRouter.route(schema, layout); + + expect(routes.length, schema.relations.length); + for (final r in routes) { + expect(r.points.length, greaterThanOrEqualTo(2)); + } + }); + }); } /// Passes every call on and records the row limit it was given. @@ -1225,29 +1250,4 @@ class _LimitRecordingDelegate extends SqlExecutionDelegate { @override bool get supportsTransactions => false; - - group('router on a larger schema (#1168)', () { - test('a 100-table schema routes every relation with a real path', () { - final columns = >[]; - final fks = >[]; - for (var i = 0; i < 100; i++) { - columns.add(['t$i', 'id', 'int', '1', '0']); - columns.add(['t$i', 'parent_id', 'int', '0', '1']); - if (i > 0) { - fks.add(['t$i', 'parent_id', 't${i ~/ 2}', 'id']); - } - if (i > 10 && i % 7 == 0) { - fks.add(['t$i', 'parent_id', 't${i - 9}', 'id']); - } - } - final schema = ErdSchema.fromCatalog(columnRows: columns, fkRows: fks); - final layout = ErdLayout.compute(schema); - final routes = ErdRouter.route(schema, layout); - - expect(routes.length, schema.relations.length); - for (final r in routes) { - expect(r.points.length, greaterThanOrEqualTo(2)); - } - }); - }); }