Skip to content

Commit 3d71415

Browse files
tyaginidhiclaude
andcommitted
Fix FFD bin-packing under-allocation that overflowed the schema-attr cap
deriveDomainsByCapacity seeded the packer's bin count from a LOWER bound (max(ceil(tables/maxTableCount), ceil(attrs/maxSchemaAttrs))), so when independent (no-edge) clusters fragment, FFD ran out of bins and dropped the non-fitting cluster into the least-loaded bucket — overflowing it past maxSchemaAttrs with no warning (the oversized-cluster guard only checks per- cluster table COUNT, not attrs). Verified repro: 4 independent 8000-attr tables, maxSchemaAttrs 15000 -> seed n=3 -> one bucket holds 16000 attrs. Seed the packer with the maximum permitted bins instead (one per cluster, capped at maxSchemaSplitSolutions). FFD still consolidates — clusters that fit together share a bin and empty bins are dropped, so the solution count stays minimal — but a cluster that fits nowhere opens a NEW bin rather than overflowing. The existing 16000-attr/2-solution test is unchanged (FFD still consolidates); added a regression test for the 4-independent-table overflow case. 1211 tests pass, alm-lint 0 findings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a02eb2b commit 3d71415

2 files changed

Lines changed: 42 additions & 7 deletions

File tree

plugins/power-pages/scripts/lib/compute-split-plan.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,16 @@ function deriveDomainsByCapacity(estimate, thresholds) {
427427
if (tables.length === 0) return [{ name: 'Tables', tableLogicalNames: [] }];
428428

429429
const clusters = buildTableClusters(tables, estimate.tableRelationships || []);
430-
const totalAttrs = tables.reduce((s, t) => s + t.attributeCount, 0);
431430
const ceiling = (thresholds && thresholds.maxSchemaSplitSolutions) || 8;
432-
let n = Math.max(
433-
1,
434-
Math.ceil(tables.length / thresholds.maxTableCount),
435-
Math.ceil(totalAttrs / Math.max(thresholds.maxSchemaAttrs, 1)),
436-
);
437-
n = Math.min(n, ceiling, clusters.length);
431+
// Seed the packer with the maximum permitted bins (one per cluster, capped at
432+
// maxSchemaSplitSolutions). First-fit-decreasing still consolidates — clusters
433+
// that fit together share a bin and the empty bins are dropped, so the final
434+
// count stays minimal — but a cluster that fits nowhere lands in a NEW bin
435+
// instead of overflowing an existing one. Seeding from a lower bound
436+
// (ceil(tables/maxTable), ceil(attrs/maxAttr)) under-allocated bins and let
437+
// independent attr-heavy clusters bust maxSchemaAttrs in the least-loaded
438+
// bucket, unwarned (the oversized guard only catches per-cluster table count).
439+
const n = Math.min(clusters.length, ceiling);
438440

439441
const buckets = packClusters(clusters, n, thresholds);
440442
const multi = buckets.length > 1;

plugins/power-pages/scripts/tests/compute-split-plan.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,39 @@ test('computeSplitPlan Strategy 3 packs tables by capacity (no domains configure
205205
assert.equal(tableSolutions.length, 2, '16000 attrs / 15000 cap -> 2 Table solutions, not one-per-table');
206206
});
207207

208+
test('computeSplitPlan Strategy 3 never overflows the attr cap when independent clusters fragment', () => {
209+
// Regression for the FFD under-allocation bug: 4 INDEPENDENT (no-edge) tables of
210+
// 8000 attrs each = 32000 total. Seeding the packer from the lower bound
211+
// ceil(32000/15000)=3 gave only 3 bins, so the 4th cluster fell into the
212+
// least-loaded bucket -> 16000 attrs (> 15000 cap), unwarned. The packer must
213+
// instead open a 4th bin (clusters.length permits it) so no bucket busts the cap.
214+
const result = computeSplitPlan({
215+
estimate: baseEstimate({
216+
tableCount: 4,
217+
schemaAttrCount: 32000,
218+
tables: [
219+
{ logicalName: 'tst_alpha', attributeCount: 8000 },
220+
{ logicalName: 'tst_beta', attributeCount: 8000 },
221+
{ logicalName: 'tst_gamma', attributeCount: 8000 },
222+
{ logicalName: 'tst_delta', attributeCount: 8000 },
223+
],
224+
tableRelationships: [], // no edges -> 4 singleton clusters
225+
}),
226+
config: baseConfig(),
227+
meta: { baseName: 'Test', siteName: 'Test Site' },
228+
});
229+
assert.equal(result.splitStrategy, 'strategy-3-schema-segmentation');
230+
const tableSolutions = result.proposedSolutions.filter(
231+
(s) => Array.isArray(s.componentTypes) && s.componentTypes.length === 1 && s.componentTypes[0] === 'Table',
232+
);
233+
// 4 independent 8000-attr tables -> 4 single-table solutions (each 8000 < 15000),
234+
// NOT 3 with one 16000-attr overflow bucket.
235+
assert.equal(tableSolutions.length, 4, '4 independent 8000-attr tables -> 4 Table solutions (no attr-cap overflow)');
236+
for (const s of tableSolutions) {
237+
assert.equal(s.tableLogicalNames.length, 1, `${s.uniqueName} must hold exactly one table — no bucket over the attr cap`);
238+
}
239+
});
240+
208241
test('computeSplitPlan additive Strategy 4 prepends EnvVars solution', () => {
209242
const result = computeSplitPlan({
210243
estimate: baseEstimate({ totalSizeMB: 142, webFilesAggregateMB: 110, envVarCount: 800 }),

0 commit comments

Comments
 (0)