UNPKG

@clickup/pg-microsharding

Version:
374 lines (340 loc) 11.6 kB
import assert from "assert"; import compact from "lodash/compact"; import difference from "lodash/difference"; import flatten from "lodash/flatten"; import groupBy from "lodash/groupBy"; import minBy from "lodash/minBy"; import sortBy from "lodash/sortBy"; import sumBy from "lodash/sumBy"; import without from "lodash/without"; const SHARD_WEIGHT_MOVE_FROM_OVERLOADED_TO_OVERLOADED_FACTOR = 0.75; const DEFAULT_FRACTION_OF_MEDIAN_TO_CONSIDER_EMPTY = 0.1; export interface Shard { weight: number; } interface Island { no: number; sumWeight: number; shards: Set<Shard>; } /** * Accepts a list of islands (an island is a collection of shards with different * weights). Modifies it to make the shards being distributed more fairly. * Returns the list of shards moves. * * ChatGPT mentions several related academical problems: * - "linear partitioning" * - "balanced partition" * - "partition problem" * * Unfortunately, none of the above approaches works out of the box due to * corner cases and heuristics we have. * * The current algorithm implemented is not perfect intentionally: it doesn't * try to achieve a fully-fair distribution, because it would produce way too * many moves otherwise (shards moves are expensive). Instead, it makes * trade-offs: * 1. Tries to unload the largest "overloaded" shards to the smallest island if * such island wouldn't appear overloaded after that. * 2. If there is no such island in (1) (i.e. no matter where we move it, the * destination will be overloaded), it may still move the shard to the * smallest island, BUT only it the final benefit of the move would be more * than SHARD_WEIGHT_MOVE_FROM_OVERLOADED_TO_OVERLOADED_FACTOR fraction of * the shard's weight. (We don't want to pay the price for a move which won't * move the needle much.) * 3. Compensates large shards relocations with the corresponding number of * small shard relocations, so largest shards and smallest shards are * redistributed more or less in sync with each other, and there will be e.g. * no island with just 2 biggest shards, whilst other islands have tens of * them. * 4. Also, a special treatment is applied to "empty" shards. They are treated * as "filled in the future". The shard is considered "empty" if its weight * is less than fractionOfMedianToConsiderEmpty of a median shard's weight. * Such shards are distributed uniformly among the islands, not looking at * their weights; this prevents the situation when most of the "empty" shards * appear on the same island in the end. * * All those trade-offs produce a slightly imbalanced result. In real life, it * doesn't matter much though, because shards sizes are more or less equal. */ export function rebalance<TShard extends Shard>( islands: Map<number, readonly TShard[]>, decommissionIslandNos: number[] = [], fractionOfMedianToConsiderEmpty = DEFAULT_FRACTION_OF_MEDIAN_TO_CONSIDER_EMPTY, ): Array<{ from: number; to: number; shards: TShard[] }> { const islandsFrom = new Map<number, Island>( [...islands.entries()].map(([no, shards]) => [ no, { no, sumWeight: sumBy(shards, ({ weight }) => weight), shards: new Set(shards), }, ]), ); const islandsTo = new Map( [...islandsFrom.entries()].map(([no, island]) => [ no, { ...island, shards: new Set([...island.shards]) }, ]), ); decommissionIslands(islandsTo, decommissionIslandNos); const emptyShards = extractEmptyShards( islandsTo, fractionOfMedianToConsiderEmpty, ); balanceIslands(islandsTo); injectShardsUniformly(islandsTo, emptyShards); const moves = buildMoves(islandsFrom, islandsTo); const shardsTo = new Map( [...islandsTo.values()].map(({ no, shards }) => [ no, [...shards] as TShard[], ]), ); const shardsFromOrder = new Map( flatten([...islandsFrom.values()].map(({ shards }) => [...shards])).map( (shard, i) => [shard, i], ), ); islands.clear(); for (const islandNo of islandsFrom.keys()) { islands.set( islandNo, sortBy( shardsTo.get(islandNo) ?? [], (shard) => shardsFromOrder.get(shard) ?? Infinity, ), ); } return Object.values(groupBy(moves, ({ from, to }) => `${from}:${to}`)).map( (moves) => ({ from: moves[0].from, to: moves[0].to, shards: moves.map(({ shard }) => shard as TShard), }), ); } /** * Performs islands re-balancing in-place. */ function balanceIslands(islands: Map<number, Island>): void { if (islands.size === 0) { return; } const shardsSortedDesc = sortBy( flatten( [...islands.values()].map((island) => [...island.shards].map((shard) => [shard, island] as const), ), ), ([shard]) => -1 * shard.weight, ); const desiredIslandWeight = sumBy(shardsSortedDesc, ([{ weight }]) => weight) / islands.size; let bigShardMovedWeight = 0; while (shardsSortedDesc.length > 0) { // First, try to evacuate ONE biggest shard of an OVERLOADED island to the // smallest island ONLY if it wouldn't overload it. Do it until we actually // succeed moving ONE shard (and skip the shards we couldn't find the new // "better" island for). while (shardsSortedDesc.length > 0) { const [shard, islandFrom] = shardsSortedDesc.shift()!; // biggest shard if (islandFrom.sumWeight > desiredIslandWeight) { // The island of this shard is overloaded, so try to move the shard off // to OTHER SMALLEST island. const islandTo = minBy( without([...islands.values()], islandFrom), ({ sumWeight }) => sumWeight, ); if (islandTo) { const islandToNewWeight = islandTo.sumWeight + shard.weight; if ( islandToNewWeight <= desiredIslandWeight || islandFrom.sumWeight - islandToNewWeight > shard.weight * SHARD_WEIGHT_MOVE_FROM_OVERLOADED_TO_OVERLOADED_FACTOR ) { // The new island would NOT be overloaded after the move, so it's // better than the currently overloaded island. moveShard({ shard, islandFrom, islandTo }); bigShardMovedWeight += shard.weight; break; } } } } // Then, to compensate this biggest shard relocation, redistribute N // SMALLEST shards of OVERLOADED islands to the smallest islands up until we // accumulate the total weight of the biggest shard we have just // redistributed. while (shardsSortedDesc.length > 0) { const [shard, islandFrom] = shardsSortedDesc[shardsSortedDesc.length - 1]; // smallest shard if (islandFrom.sumWeight > desiredIslandWeight) { if (bigShardMovedWeight >= shard.weight) { const islandTo = minBy( without([...islands.values()], islandFrom), ({ sumWeight }) => sumWeight, ); if ( islandTo && islandTo.sumWeight < islandFrom.sumWeight && score(islands, { shard, islandFrom, islandTo }) > score(islands) ) { shardsSortedDesc.pop(); moveShard({ shard, islandFrom, islandTo }); bigShardMovedWeight -= shard.weight; continue; } } } break; } } } /** * Removes islands which need to be decommissioned from the list of islands and * redistributes their shards among the smallest islands, iteratively. This * covers a common use case when we decommission an island and add another empty * one at the same time: the shards are then just moved to the new island in * this case. */ function decommissionIslands( islands: Map<number, Island>, decommissionIslandNos: number[], ): void { if (islands.size === 0 || decommissionIslandNos.length === 0) { return; } const shards: Shard[] = []; for (const [no, island] of islands) { if (decommissionIslandNos.includes(no)) { shards.push(...island.shards); islands.delete(no); } } if (islands.size === 0) { throw "Can't decommission all islands: we need at least one remaining to put all the microshards on."; } const islandsList = [...islands.values()]; for (const shard of sortBy(shards, (shard) => -1 * shard.weight)) { const smallestIsland = minBy(islandsList, (island) => island.sumWeight)!; smallestIsland.shards.add(shard); smallestIsland.sumWeight += shard.weight; } } /** * Removes shards smaller than a threshold from the islands and returns the * removed shards. */ function extractEmptyShards( islands: Map<number, Island>, fractionOfMedianToConsiderEmpty: number, ): Shard[] { const shardsSortedAsc = sortBy( flatten([...islands.values()].map((island) => [...island.shards])), ({ weight }) => weight, ); if (shardsSortedAsc.length === 0) { return []; } const weightMedian = shardsSortedAsc[Math.trunc(shardsSortedAsc.length / 2)].weight; const weightThreshold = weightMedian * fractionOfMedianToConsiderEmpty; const emptyShards = []; for (const island of islands.values()) { const empty = [...island.shards].filter( ({ weight }) => weight <= weightThreshold, ); island.shards = new Set(difference([...island.shards], empty)); emptyShards.push(...empty); } return emptyShards; } /** * Injects shards to the list of islands uniformly, not considering their * weights. */ function injectShardsUniformly( islands: Map<number, Island>, shards: Shard[], ): void { const islandList = [...islands.values()]; for (const shard of shards) { islandList[0].shards.add(shard); islandList[0].sumWeight += shard.weight; islandList.push(islandList.shift()!); } } /** * Builds the list of moves which would turn one list of islands into another * list of islands. */ function buildMoves( from: Map<number, Island>, to: Map<number, Island>, ): Array<{ from: number; to: number; shard: Shard }> { const shardsToIsland = new Map( flatten( [...to.values()].map((island) => [...island.shards].map((shard) => [shard, island] as const), ), ), ); return flatten( [...from.values()].map((from) => compact( [...from.shards].map((shard) => { const to = shardsToIsland.get(shard)!; return from.no === to.no ? null : { from: from.no, to: shardsToIsland.get(shard)!.no, shard, }; }), ), ), ); } /** * Score of the cluster is the minimal sum weight among its islands. The bigger * the score, the more fair is the distribution. */ function score( islands: Map<number, Island>, afterMove?: { shard: Shard; islandFrom: Island; islandTo: Island; }, ): number { return Math.min( ...[...islands.values()].map( (island) => island.sumWeight - (afterMove?.islandFrom === island ? afterMove.shard.weight : 0) + (afterMove?.islandTo === island ? afterMove.shard.weight : 0), ), ); } /** * Moves a shard from one island to another updating the island's sum weight. */ function moveShard({ shard, islandFrom, islandTo, }: { shard: Shard; islandFrom: Island; islandTo: Island; }): void { assert(islandFrom.shards.has(shard)); assert(!islandTo.shards.has(shard)); islandFrom.shards.delete(shard); islandFrom.sumWeight -= shard.weight; islandTo.shards.add(shard); islandTo.sumWeight += shard.weight; }