@clickup/pg-microsharding
Version:
Microshards support for PostgreSQL
374 lines (340 loc) • 11.6 kB
text/typescript
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;
}