@pithy-sh/leaderboard 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board/registry.d.ts +22 -0
- package/dist/board/registry.d.ts.map +1 -0
- package/dist/board/registry.js +52 -0
- package/dist/capability.d.ts +43 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +73 -0
- package/dist/config/boardKey.d.ts +14 -0
- package/dist/config/boardKey.d.ts.map +1 -0
- package/dist/config/boardKey.js +16 -0
- package/dist/config/config.d.ts +96 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +121 -0
- package/dist/data/boardRecord.d.ts +36 -0
- package/dist/data/boardRecord.d.ts.map +1 -0
- package/dist/data/boardRecord.js +31 -0
- package/dist/data/entry.d.ts +29 -0
- package/dist/data/entry.d.ts.map +1 -0
- package/dist/data/entry.js +30 -0
- package/dist/data/lock.d.ts +18 -0
- package/dist/data/lock.d.ts.map +1 -0
- package/dist/data/lock.js +19 -0
- package/dist/data/tables.d.ts +25 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +29 -0
- package/dist/entry/store.d.ts +40 -0
- package/dist/entry/store.d.ts.map +1 -0
- package/dist/entry/store.js +84 -0
- package/dist/error/errors.d.ts +54 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +76 -0
- package/dist/http/guard.d.ts +24 -0
- package/dist/http/guard.d.ts.map +1 -0
- package/dist/http/guard.js +48 -0
- package/dist/http/handlers.d.ts +79 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +121 -0
- package/dist/http/routes.d.ts +46 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +60 -0
- package/dist/http/schemas.d.ts +45 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +36 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/migrations/0001_entries.d.ts +8 -0
- package/dist/migrations/0001_entries.d.ts.map +1 -0
- package/dist/migrations/0001_entries.js +33 -0
- package/dist/rank/lock.d.ts +37 -0
- package/dist/rank/lock.d.ts.map +1 -0
- package/dist/rank/lock.js +54 -0
- package/dist/rank/materialize.d.ts +95 -0
- package/dist/rank/materialize.d.ts.map +1 -0
- package/dist/rank/materialize.js +140 -0
- package/dist/rank/query.d.ts +50 -0
- package/dist/rank/query.d.ts.map +1 -0
- package/dist/rank/query.js +140 -0
- package/dist/rank/retryPolicy.d.ts +33 -0
- package/dist/rank/retryPolicy.d.ts.map +1 -0
- package/dist/rank/retryPolicy.js +37 -0
- package/dist/rank/segment.d.ts +33 -0
- package/dist/rank/segment.d.ts.map +1 -0
- package/dist/rank/segment.js +35 -0
- package/dist/rank/tiers.d.ts +13 -0
- package/dist/rank/tiers.d.ts.map +1 -0
- package/dist/rank/tiers.js +22 -0
- package/dist/rank/worker.d.ts +59 -0
- package/dist/rank/worker.d.ts.map +1 -0
- package/dist/rank/worker.entry.d.ts +57 -0
- package/dist/rank/worker.entry.d.ts.map +1 -0
- package/dist/rank/worker.entry.js +63 -0
- package/dist/rank/worker.js +52 -0
- package/dist/retention/prune.d.ts +39 -0
- package/dist/retention/prune.d.ts.map +1 -0
- package/dist/retention/prune.js +65 -0
- package/dist/seeds/example.d.ts +11 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +67 -0
- package/dist/session/bookmark.d.ts +47 -0
- package/dist/session/bookmark.d.ts.map +1 -0
- package/dist/session/bookmark.js +41 -0
- package/dist/version.generated.d.ts +5 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +7 -0
- package/dist/window/schedule.d.ts +29 -0
- package/dist/window/schedule.d.ts.map +1 -0
- package/dist/window/schedule.js +106 -0
- package/package.json +21 -10
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { LeaderboardBoard } from "../config/config";
|
|
2
|
+
import { LeaderboardEntry } from "../data/entry";
|
|
3
|
+
import { type LeaderboardDatabase } from "../data/tables";
|
|
4
|
+
export interface RankedEntry {
|
|
5
|
+
userId: string;
|
|
6
|
+
score: number;
|
|
7
|
+
achievedAt: Date;
|
|
8
|
+
rank: number;
|
|
9
|
+
tier: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface ReadOptions {
|
|
12
|
+
/** Restrict the board to these players — a friends or cohort view over the same store, not a second board. */
|
|
13
|
+
segment?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A page of the board, best first. Ranks are numbered from the offset: the ordering is total, so row
|
|
17
|
+
* `n` of an offset page is rank `offset + n + 1` by construction — no count, no window function.
|
|
18
|
+
*/
|
|
19
|
+
export declare function topEntries(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, limit: number, offset?: number, options?: ReadOptions): Promise<RankedEntry[]>;
|
|
20
|
+
/**
|
|
21
|
+
* The player's own rank, or null if they have no visible entry.
|
|
22
|
+
*
|
|
23
|
+
* When `materialized` is set the stored rank column is read — one indexed point read, and the reason
|
|
24
|
+
* `rank: { materialize }` exists. Otherwise the rank is counted live: correct always, free under ~10k
|
|
25
|
+
* players, and O(rank position) in billed rows because D1 bills rows *scanned*, not returned. A stale
|
|
26
|
+
* materialized rank is deliberate; the player's own score beside it is always live.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rankOf(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, userId: string, materialized: boolean, options?: ReadOptions): Promise<{
|
|
29
|
+
entry: LeaderboardEntry;
|
|
30
|
+
rank: number | null;
|
|
31
|
+
tier: string | null;
|
|
32
|
+
} | null>;
|
|
33
|
+
/**
|
|
34
|
+
* The live-rank count: how many visible entries beat this one. Exported so `plan.workers.test.ts` can
|
|
35
|
+
* compile the real query and read D1's own `EXPLAIN QUERY PLAN` for it — a plan test that hand-wrote the
|
|
36
|
+
* SQL would only prove that the hand-written SQL is indexed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function rankCountQuery(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, entry: Pick<LeaderboardEntry, "score" | "achievedAt" | "userId">, options?: ReadOptions): import("kysely").SelectQueryBuilder<import("@pithy-sh/core/src/data/db").DatabaseSchema<import("../data/tables").LeaderboardTables>, "pithyLeaderboardEntries", {
|
|
39
|
+
better: number;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* The slice of the board centered on a player: `radius` entries either side of them.
|
|
43
|
+
*
|
|
44
|
+
* Built on the total ordering — find the player's rank, then page the board around it — so it needs no
|
|
45
|
+
* second index and no window function. Rank is always counted live here even when the board is
|
|
46
|
+
* materialized: an "around me" page whose neighbors came from a stale rank column would show players
|
|
47
|
+
* who are no longer next to you.
|
|
48
|
+
*/
|
|
49
|
+
export declare function entriesAround(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, userId: string, radius: number, options?: ReadOptions): Promise<RankedEntry[]>;
|
|
50
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/rank/query.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AA2CrF,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,IAAI,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,8GAA8G;IAC9G,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AA4BD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,MAAM,SAAI,EACV,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,WAAW,EAAE,CAAC,CAqBxB;AAED;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,OAAO,EACrB,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBvF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,YAAY,GAAG,QAAQ,CAAC,EAChE,OAAO,GAAE,WAAgB;;GAc1B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,WAAW,EAAE,CAAC,CAKxB"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { LeaderboardEntry } from "../data/entry.js";
|
|
2
|
+
import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables.js";
|
|
3
|
+
import "./segment.js";
|
|
4
|
+
import { classifyTier } from "./tiers.js";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
import { MAX_BOUND_PARAMETERS, boundParameterBudget } from "@pithy-sh/core/src/data/boundParameters";
|
|
8
|
+
//#region src/rank/query.ts
|
|
9
|
+
/**
|
|
10
|
+
* Ranked reads: top-N, my-rank, and around-me.
|
|
11
|
+
*
|
|
12
|
+
* Two things shape every query here.
|
|
13
|
+
*
|
|
14
|
+
* **No window functions.** `RANK() OVER` and `ROW_NUMBER() OVER` are undocumented on D1 — Cloudflare's
|
|
15
|
+
* SQL reference neither supports nor denies them. They do execute under Miniflare, but Miniflare also
|
|
16
|
+
* rejects `sqlite_version()` with `not authorized to use function`, which proves D1 runs a function
|
|
17
|
+
* authorizer whose production allowlist is not visible from local. A local pass is therefore not evidence
|
|
18
|
+
* about production, so ranking is built on plain `COUNT(*)` and `ORDER BY` instead. See docs/costs.md.
|
|
19
|
+
*
|
|
20
|
+
* **The ordering is total.** Score, then earliest `achievedAt`, then `userId`. Because no two entries can
|
|
21
|
+
* tie, a rank is exactly "how many entries beat you, plus one" — so dense-vs-competition ranking never
|
|
22
|
+
* arises and neither is implemented. It also means a top-N page can number its own rows from the offset
|
|
23
|
+
* rather than asking the database to rank them.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The members a segment query may bind, or a refusal naming the cap.
|
|
27
|
+
*
|
|
28
|
+
* The HTTP boundary refuses an oversized segment already, which is why this was never a live defect —
|
|
29
|
+
* and is exactly why it is worth fixing anyway. `topEntries` and `rankOf` are exported: an adopter
|
|
30
|
+
* calling them directly got no such refusal, and a 120-friend segment reached D1 with 124 parameters.
|
|
31
|
+
* A rule enforced at one of several entrances is not enforced (#250).
|
|
32
|
+
*
|
|
33
|
+
* A refusal rather than a silent truncation: a segment is a *set of people*, and quietly ranking 80 of
|
|
34
|
+
* somebody's 120 friends would be a wrong answer presented as a right one.
|
|
35
|
+
*/
|
|
36
|
+
function segmentMembers(segment) {
|
|
37
|
+
if (segment.length > Math.min(80, boundParameterBudget(10))) throw new ValidationError({
|
|
38
|
+
message: `A segment is capped at 80 players.`,
|
|
39
|
+
action: `Rank at most 80 players at a time.`,
|
|
40
|
+
detail: `A segment of ${segment.length} exceeds the cap of 80; D1 accepts ${MAX_BOUND_PARAMETERS} bound parameters and a segment query spends 10 of them before a single member.`
|
|
41
|
+
});
|
|
42
|
+
return [...segment];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* "Strictly better than this entry", in the board's direction.
|
|
46
|
+
*
|
|
47
|
+
* Spelled out as an OR-of-ANDs rather than a row-value comparison (`(score, achieved_at) > (?, ?)`)
|
|
48
|
+
* because the sort directions are mixed — score descends while achievedAt ascends — and SQLite's
|
|
49
|
+
* row-value shortcut only applies when every key sorts the same way.
|
|
50
|
+
*/
|
|
51
|
+
function betterThan(board, score, achievedAt, userId) {
|
|
52
|
+
const scoreBeats = board.direction === "desc" ? sql`score > ${score}` : sql`score < ${score}`;
|
|
53
|
+
return sql`(
|
|
54
|
+
${scoreBeats}
|
|
55
|
+
OR (score = ${score} AND achieved_at < ${achievedAt})
|
|
56
|
+
OR (score = ${score} AND achieved_at = ${achievedAt} AND user_id < ${userId})
|
|
57
|
+
)`;
|
|
58
|
+
}
|
|
59
|
+
function decorate(board, entry, rank) {
|
|
60
|
+
return {
|
|
61
|
+
userId: entry.userId,
|
|
62
|
+
score: entry.score,
|
|
63
|
+
achievedAt: entry.achievedAt,
|
|
64
|
+
rank,
|
|
65
|
+
tier: classifyTier(board.tiers, board.direction, entry.score)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A page of the board, best first. Ranks are numbered from the offset: the ordering is total, so row
|
|
70
|
+
* `n` of an offset page is rank `offset + n + 1` by construction — no count, no window function.
|
|
71
|
+
*/
|
|
72
|
+
async function topEntries(db, board, windowKey, limit, offset = 0, options = {}) {
|
|
73
|
+
let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).selectAll().where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
|
|
74
|
+
if (options.segment) {
|
|
75
|
+
if (options.segment.length === 0) return [];
|
|
76
|
+
query = query.where("userId", "in", segmentMembers(options.segment));
|
|
77
|
+
}
|
|
78
|
+
return (await query.orderBy("score", board.direction === "desc" ? "desc" : "asc").orderBy("achievedAt", "asc").orderBy("userId", "asc").limit(limit).offset(offset).execute()).map((row, index) => decorate(board, LeaderboardEntry.parse(row), offset + index + 1));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The player's own rank, or null if they have no visible entry.
|
|
82
|
+
*
|
|
83
|
+
* When `materialized` is set the stored rank column is read — one indexed point read, and the reason
|
|
84
|
+
* `rank: { materialize }` exists. Otherwise the rank is counted live: correct always, free under ~10k
|
|
85
|
+
* players, and O(rank position) in billed rows because D1 bills rows *scanned*, not returned. A stale
|
|
86
|
+
* materialized rank is deliberate; the player's own score beside it is always live.
|
|
87
|
+
*/
|
|
88
|
+
async function rankOf(db, board, windowKey, userId, materialized, options = {}) {
|
|
89
|
+
const row = await db.selectFrom(LEADERBOARD_ENTRIES_TABLE).selectAll().where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("userId", "=", userId).executeTakeFirst();
|
|
90
|
+
if (!row) return null;
|
|
91
|
+
const entry = LeaderboardEntry.parse(row);
|
|
92
|
+
const tier = classifyTier(board.tiers, board.direction, entry.score);
|
|
93
|
+
if (!entry.visible || entry.hidden) return {
|
|
94
|
+
entry,
|
|
95
|
+
rank: null,
|
|
96
|
+
tier
|
|
97
|
+
};
|
|
98
|
+
if (materialized && !options.segment) return {
|
|
99
|
+
entry,
|
|
100
|
+
rank: entry.rank ?? null,
|
|
101
|
+
tier
|
|
102
|
+
};
|
|
103
|
+
if (options.segment?.length === 0) return {
|
|
104
|
+
entry,
|
|
105
|
+
rank: null,
|
|
106
|
+
tier
|
|
107
|
+
};
|
|
108
|
+
const counted = await rankCountQuery(db, board, windowKey, entry, options).executeTakeFirst();
|
|
109
|
+
return {
|
|
110
|
+
entry,
|
|
111
|
+
rank: Number(counted?.better ?? 0) + 1,
|
|
112
|
+
tier
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The live-rank count: how many visible entries beat this one. Exported so `plan.workers.test.ts` can
|
|
117
|
+
* compile the real query and read D1's own `EXPLAIN QUERY PLAN` for it — a plan test that hand-wrote the
|
|
118
|
+
* SQL would only prove that the hand-written SQL is indexed.
|
|
119
|
+
*/
|
|
120
|
+
function rankCountQuery(db, board, windowKey, entry, options = {}) {
|
|
121
|
+
let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).select(({ fn }) => fn.countAll().as("better")).where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
|
|
122
|
+
if (options.segment && options.segment.length > 0) query = query.where("userId", "in", segmentMembers(options.segment));
|
|
123
|
+
return query.where(betterThan(board, entry.score, entry.achievedAt.getTime(), entry.userId));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The slice of the board centered on a player: `radius` entries either side of them.
|
|
127
|
+
*
|
|
128
|
+
* Built on the total ordering — find the player's rank, then page the board around it — so it needs no
|
|
129
|
+
* second index and no window function. Rank is always counted live here even when the board is
|
|
130
|
+
* materialized: an "around me" page whose neighbors came from a stale rank column would show players
|
|
131
|
+
* who are no longer next to you.
|
|
132
|
+
*/
|
|
133
|
+
async function entriesAround(db, board, windowKey, userId, radius, options = {}) {
|
|
134
|
+
const own = await rankOf(db, board, windowKey, userId, false, options);
|
|
135
|
+
if (!own || own.rank === null) return [];
|
|
136
|
+
const offset = Math.max(0, own.rank - radius - 1);
|
|
137
|
+
return topEntries(db, board, windowKey, radius * 2 + 1, offset, options);
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { entriesAround, rankCountQuery, rankOf, topEntries };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
2
|
+
/**
|
|
3
|
+
* **What the rank refresh retries, and what it refuses to.**
|
|
4
|
+
*
|
|
5
|
+
* The answer is short because the refresh's world is: every step it runs — the journalled context, the
|
|
6
|
+
* prune, and one keyset page of ranking per step — talks to D1 and to nothing else. There is no
|
|
7
|
+
* provider, no bucket, no model, no second account. So there is no `leaderboard/*` code this pass can
|
|
8
|
+
* usefully re-drive, and the record is empty on purpose (pithy-sh/pithy#348).
|
|
9
|
+
*
|
|
10
|
+
* **An empty record is a statement, not an omission.** Core still answers for D1 through `withD1Retry`'s
|
|
11
|
+
* vocabulary — busy, timed out, connection lost, storage reset, internal — so a database under
|
|
12
|
+
* contention is re-driven with the step's much longer backoff, and nothing about that is restated here:
|
|
13
|
+
* one D1 vocabulary, in core, or the two drift. What the empty record adds is the other half — that
|
|
14
|
+
* leaderboard has looked at its own codes and retries none of them.
|
|
15
|
+
*
|
|
16
|
+
* ## Terminal, and why
|
|
17
|
+
*
|
|
18
|
+
* - **`leaderboard/invalid_schedule`** — a board's window CRON will not parse. It is config, it is
|
|
19
|
+
* identical on the next attempt, and it wants the adopter to edit `pithy.config.ts`.
|
|
20
|
+
* - **`validation/invalid_input`** — a board whose keyset cursor or rank shape the materializer refuses.
|
|
21
|
+
* Deterministic in the row it read.
|
|
22
|
+
* - **`leaderboard/board_not_found`, `leaderboard/board_immutable`** and the rest of the submit-path
|
|
23
|
+
* codes. They belong to a request; a refresh that somehow raised one has found a bug, and a bug
|
|
24
|
+
* surfaces faster than it backs off.
|
|
25
|
+
*
|
|
26
|
+
* **The cron is the outer retry, and that is why terminal is cheap here.** A refresh fires on a
|
|
27
|
+
* schedule, takes an advisory lock, and re-ranks from the top; a run that fails releases its lock in a
|
|
28
|
+
* `finally` and the next fire does the whole job again. So a fault that stops one run costs one
|
|
29
|
+
* interval, where five platform attempts against an answer that cannot change cost the interval *and*
|
|
30
|
+
* hold the lock through it — which is the one thing that makes the next fire skip too.
|
|
31
|
+
*/
|
|
32
|
+
export declare const leaderboardWorkflowRetry: WorkflowRetryPolicy;
|
|
33
|
+
//# sourceMappingURL=retryPolicy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retryPolicy.d.ts","sourceRoot":"","sources":["../../src/rank/retryPolicy.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,wBAAwB,EAAE,mBAGtC,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/rank/retryPolicy.ts
|
|
2
|
+
/**
|
|
3
|
+
* **What the rank refresh retries, and what it refuses to.**
|
|
4
|
+
*
|
|
5
|
+
* The answer is short because the refresh's world is: every step it runs — the journalled context, the
|
|
6
|
+
* prune, and one keyset page of ranking per step — talks to D1 and to nothing else. There is no
|
|
7
|
+
* provider, no bucket, no model, no second account. So there is no `leaderboard/*` code this pass can
|
|
8
|
+
* usefully re-drive, and the record is empty on purpose (pithy-sh/pithy#348).
|
|
9
|
+
*
|
|
10
|
+
* **An empty record is a statement, not an omission.** Core still answers for D1 through `withD1Retry`'s
|
|
11
|
+
* vocabulary — busy, timed out, connection lost, storage reset, internal — so a database under
|
|
12
|
+
* contention is re-driven with the step's much longer backoff, and nothing about that is restated here:
|
|
13
|
+
* one D1 vocabulary, in core, or the two drift. What the empty record adds is the other half — that
|
|
14
|
+
* leaderboard has looked at its own codes and retries none of them.
|
|
15
|
+
*
|
|
16
|
+
* ## Terminal, and why
|
|
17
|
+
*
|
|
18
|
+
* - **`leaderboard/invalid_schedule`** — a board's window CRON will not parse. It is config, it is
|
|
19
|
+
* identical on the next attempt, and it wants the adopter to edit `pithy.config.ts`.
|
|
20
|
+
* - **`validation/invalid_input`** — a board whose keyset cursor or rank shape the materializer refuses.
|
|
21
|
+
* Deterministic in the row it read.
|
|
22
|
+
* - **`leaderboard/board_not_found`, `leaderboard/board_immutable`** and the rest of the submit-path
|
|
23
|
+
* codes. They belong to a request; a refresh that somehow raised one has found a bug, and a bug
|
|
24
|
+
* surfaces faster than it backs off.
|
|
25
|
+
*
|
|
26
|
+
* **The cron is the outer retry, and that is why terminal is cheap here.** A refresh fires on a
|
|
27
|
+
* schedule, takes an advisory lock, and re-ranks from the top; a run that fails releases its lock in a
|
|
28
|
+
* `finally` and the next fire does the whole job again. So a fault that stops one run costs one
|
|
29
|
+
* interval, where five platform attempts against an answer that cannot change cost the interval *and*
|
|
30
|
+
* hold the lock through it — which is the one thing that makes the next fire skip too.
|
|
31
|
+
*/
|
|
32
|
+
const leaderboardWorkflowRetry = {
|
|
33
|
+
capability: "leaderboard",
|
|
34
|
+
retryable: {}
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { leaderboardWorkflowRetry };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The segment cap, in the one module that has no reason to import anything (#430).
|
|
3
|
+
*
|
|
4
|
+
* `http/schemas.ts` states the same bound a caller is refused by, so it needs this number — and it used
|
|
5
|
+
* to reach it through `rank/query.ts`, which builds the SQL and therefore pulls Kysely, `kysely-d1` and
|
|
6
|
+
* `@cloudflare/workers-types` behind it. A request schema is a client's business: a management client
|
|
7
|
+
* building a call must be able to compile the shape it may send, in a browser, with no Worker types in
|
|
8
|
+
* reach. So the number moved and the query kept the query.
|
|
9
|
+
*
|
|
10
|
+
* **The relationship this number only means something against lives elsewhere, on purpose.**
|
|
11
|
+
* `boundParameterBudget` is in `@pithy-sh/core/src/data/boundParameters`, which imports `D1Database` for
|
|
12
|
+
* the guard beside it, so importing it here would put the data layer back under a browser program by a
|
|
13
|
+
* shorter route. `rank/query.workers.test.ts` asserts `MAX_SEGMENT_SIZE <=
|
|
14
|
+
* boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` where the budget is already in scope, and that
|
|
15
|
+
* assertion is what ties these two constants to D1's ceiling. Moving them without it detaches the cap
|
|
16
|
+
* from the limit it exists for, which is #250 with no symptom until real data arrives.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* What a segment query binds besides the members: 4 filters (boardId, windowKey, visible, hidden) plus
|
|
20
|
+
* the 6 of `betterThan` (score twice, achieved_at twice, score and userId again). `rankOf` within a
|
|
21
|
+
* segment is the tightest path, so its overhead is the one that sets the cap.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SEGMENT_FIXED_PARAMETERS = 10;
|
|
24
|
+
/**
|
|
25
|
+
* Cap on a segment's member count.
|
|
26
|
+
*
|
|
27
|
+
* `boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` is 90 — the most D1 would take. 80 is deliberately
|
|
28
|
+
* inside it, so that adding one more filter to a segment query is a change to `rank/query.ts` rather
|
|
29
|
+
* than a change to what every caller may pass. `segmentMembers` asserts the relationship rather than
|
|
30
|
+
* trusting it, and `boundParameters.test.ts` in core owns the arithmetic itself.
|
|
31
|
+
*/
|
|
32
|
+
export declare const MAX_SEGMENT_SIZE = 80;
|
|
33
|
+
//# sourceMappingURL=segment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"segment.d.ts","sourceRoot":"","sources":["../../src/rank/segment.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAE3C;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,KAAK,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/rank/segment.ts
|
|
2
|
+
/**
|
|
3
|
+
* The segment cap, in the one module that has no reason to import anything (#430).
|
|
4
|
+
*
|
|
5
|
+
* `http/schemas.ts` states the same bound a caller is refused by, so it needs this number — and it used
|
|
6
|
+
* to reach it through `rank/query.ts`, which builds the SQL and therefore pulls Kysely, `kysely-d1` and
|
|
7
|
+
* `@cloudflare/workers-types` behind it. A request schema is a client's business: a management client
|
|
8
|
+
* building a call must be able to compile the shape it may send, in a browser, with no Worker types in
|
|
9
|
+
* reach. So the number moved and the query kept the query.
|
|
10
|
+
*
|
|
11
|
+
* **The relationship this number only means something against lives elsewhere, on purpose.**
|
|
12
|
+
* `boundParameterBudget` is in `@pithy-sh/core/src/data/boundParameters`, which imports `D1Database` for
|
|
13
|
+
* the guard beside it, so importing it here would put the data layer back under a browser program by a
|
|
14
|
+
* shorter route. `rank/query.workers.test.ts` asserts `MAX_SEGMENT_SIZE <=
|
|
15
|
+
* boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` where the budget is already in scope, and that
|
|
16
|
+
* assertion is what ties these two constants to D1's ceiling. Moving them without it detaches the cap
|
|
17
|
+
* from the limit it exists for, which is #250 with no symptom until real data arrives.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* What a segment query binds besides the members: 4 filters (boardId, windowKey, visible, hidden) plus
|
|
21
|
+
* the 6 of `betterThan` (score twice, achieved_at twice, score and userId again). `rankOf` within a
|
|
22
|
+
* segment is the tightest path, so its overhead is the one that sets the cap.
|
|
23
|
+
*/
|
|
24
|
+
const SEGMENT_FIXED_PARAMETERS = 10;
|
|
25
|
+
/**
|
|
26
|
+
* Cap on a segment's member count.
|
|
27
|
+
*
|
|
28
|
+
* `boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` is 90 — the most D1 would take. 80 is deliberately
|
|
29
|
+
* inside it, so that adding one more filter to a segment query is a change to `rank/query.ts` rather
|
|
30
|
+
* than a change to what every caller may pass. `segmentMembers` asserts the relationship rather than
|
|
31
|
+
* trusting it, and `boundParameters.test.ts` in core owns the arithmetic itself.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_SEGMENT_SIZE = 80;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { MAX_SEGMENT_SIZE, SEGMENT_FIXED_PARAMETERS };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LeaderboardTier, ScoreDirection } from "../config/config";
|
|
2
|
+
/**
|
|
3
|
+
* Classify a score into one of the board's tiers, or null if it reaches none.
|
|
4
|
+
*
|
|
5
|
+
* Tiers are a read-side classification over the score already stored — no tier column, no write-side
|
|
6
|
+
* cost, and no second board type. Config validates that tiers are listed worst to best in the board's
|
|
7
|
+
* direction, so the last one the score qualifies for is the best one it qualifies for.
|
|
8
|
+
*
|
|
9
|
+
* This is not the bucketed-cohort model (Duolingo leagues, Clash Royale arenas), which needs durable
|
|
10
|
+
* per-window cohort assignment and provably cannot be a view over a global board. That is deferred.
|
|
11
|
+
*/
|
|
12
|
+
export declare function classifyTier(tiers: readonly LeaderboardTier[] | undefined, direction: ScoreDirection, score: number): string | null;
|
|
13
|
+
//# sourceMappingURL=tiers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tiers.d.ts","sourceRoot":"","sources":["../../src/rank/tiers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAExE;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,SAAS,eAAe,EAAE,GAAG,SAAS,EAC7C,SAAS,EAAE,cAAc,EACzB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,IAAI,CASf"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/rank/tiers.ts
|
|
2
|
+
/**
|
|
3
|
+
* Classify a score into one of the board's tiers, or null if it reaches none.
|
|
4
|
+
*
|
|
5
|
+
* Tiers are a read-side classification over the score already stored — no tier column, no write-side
|
|
6
|
+
* cost, and no second board type. Config validates that tiers are listed worst to best in the board's
|
|
7
|
+
* direction, so the last one the score qualifies for is the best one it qualifies for.
|
|
8
|
+
*
|
|
9
|
+
* This is not the bucketed-cohort model (Duolingo leagues, Clash Royale arenas), which needs durable
|
|
10
|
+
* per-window cohort assignment and provably cannot be a view over a global board. That is deferred.
|
|
11
|
+
*/
|
|
12
|
+
function classifyTier(tiers, direction, score) {
|
|
13
|
+
if (!tiers || tiers.length === 0) return null;
|
|
14
|
+
let reached = null;
|
|
15
|
+
for (const tier of tiers) {
|
|
16
|
+
if (!(direction === "desc" ? score >= tier.from : score <= tier.from)) break;
|
|
17
|
+
reached = tier.key;
|
|
18
|
+
}
|
|
19
|
+
return reached;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { classifyTier };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
2
|
+
import type { LeaderboardBoard, LeaderboardConfig } from "../config/config";
|
|
3
|
+
import { type PruneOutcome } from "../retention/prune";
|
|
4
|
+
import { type RefreshResult } from "./materialize";
|
|
5
|
+
/**
|
|
6
|
+
* The rank pass, in process: the retention sweep, then the rank refresh, each board ranked to completion.
|
|
7
|
+
*
|
|
8
|
+
* This is the reusable core the {@link RankRefreshWorkflow} drives step by step, and a standalone entry
|
|
9
|
+
* point for a caller who wants the whole pass in one call (tests, a simple scheduled handler). It runs
|
|
10
|
+
* every board to completion — there is no per-invocation chunk cap, so no board-size ceiling. The
|
|
11
|
+
* Workflow adds durability and per-board checkpointing on top of these same primitives; it does not need
|
|
12
|
+
* a different pass.
|
|
13
|
+
*
|
|
14
|
+
* Order matters. Pruning first means the refresh never spends a chunk ranking rows about to be deleted,
|
|
15
|
+
* and a board whose retention just dropped a window does not briefly publish ranks for it.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* One board's contribution to a rank pass: what the refresh did, or that it could not be done (#371).
|
|
19
|
+
*
|
|
20
|
+
* The state rides on the value. A board that threw is not a board that ranked nobody — `ranked: 0` is a
|
|
21
|
+
* real answer about an empty window — so the numbers live behind `refreshed` and a consumer reaches them
|
|
22
|
+
* only by narrowing. Forgetting the sick board is a type error rather than a zero on a dashboard.
|
|
23
|
+
*/
|
|
24
|
+
export type BoardRefresh = ({
|
|
25
|
+
/** This board's open window was ranked. */
|
|
26
|
+
state: "refreshed";
|
|
27
|
+
/** The board's key, as its config declares it. */
|
|
28
|
+
board: string;
|
|
29
|
+
/** The window key that was ranked. */
|
|
30
|
+
window: string;
|
|
31
|
+
} & RefreshResult) | {
|
|
32
|
+
/** This board's refresh threw. Its ranks are whatever the previous pass left. */
|
|
33
|
+
state: "unavailable";
|
|
34
|
+
/** The board's key, as its config declares it. */
|
|
35
|
+
board: string;
|
|
36
|
+
/** The window key the pass was attempting. */
|
|
37
|
+
window: string;
|
|
38
|
+
};
|
|
39
|
+
export interface RankPassResult {
|
|
40
|
+
/** What the retention sweep deleted, and whether it reached every board. */
|
|
41
|
+
pruned: PruneOutcome;
|
|
42
|
+
/** One entry per materialized board the refresh pass touched. Empty when rank is live. */
|
|
43
|
+
refreshed: BoardRefresh[];
|
|
44
|
+
}
|
|
45
|
+
/** The boards a refresh pass must rank: only materialized ones. Live boards compute rank per request. */
|
|
46
|
+
export declare function materializedBoards(config: LeaderboardConfig): LeaderboardBoard[];
|
|
47
|
+
/**
|
|
48
|
+
* **Every contributor here is degraded, and none of them is load-bearing (#371).**
|
|
49
|
+
*
|
|
50
|
+
* The sweep runs first so the refresh never spends a chunk ranking rows about to be deleted — an
|
|
51
|
+
* efficiency, not a precondition. A board whose prune throws keeps its old rows a while longer and ranks
|
|
52
|
+
* correctly regardless, so the sweep failing is no reason to leave every board's ranks stale. And boards
|
|
53
|
+
* are independent of each other by construction: one board's entries, windows and ranks are its own, so a
|
|
54
|
+
* board that will not rank has no claim on any other board's pass.
|
|
55
|
+
*
|
|
56
|
+
* So one sick board costs its own line. What it must never do is read as a board that ranked nobody.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runRankPass(d1: D1Database, config: LeaderboardConfig, now: Date): Promise<RankPassResult>;
|
|
59
|
+
//# sourceMappingURL=worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../src/rank/worker.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG5E,OAAO,EAAE,KAAK,YAAY,EAAe,MAAM,oBAAoB,CAAC;AAEpE,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,eAAe,CAAC;AAEvE;;;;;;;;;;;GAWG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GACpB,CAAC;IACC,2CAA2C;IAC3C,KAAK,EAAE,WAAW,CAAC;IACnB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,aAAa,CAAC,GAClB;IACE,iFAAiF;IACjF,KAAK,EAAE,aAAa,CAAC;IACrB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEN,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,MAAM,EAAE,YAAY,CAAC;IACrB,0FAA0F;IAC1F,SAAS,EAAE,YAAY,EAAE,CAAC;CAC3B;AAED,yGAAyG;AACzG,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,GAAG,gBAAgB,EAAE,CAEhF;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,cAAc,CAAC,CAsB/G"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
2
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
3
|
+
/**
|
|
4
|
+
* The leaderboard rank refresh, as a cron-triggered Cloudflare Workflow.
|
|
5
|
+
*
|
|
6
|
+
* Why a Workflow rather than a plain `scheduled()` pass: a Worker invocation is wall-clock bounded, so
|
|
7
|
+
* an earlier design capped each run at ~64k entries and re-ranked from the top every tick — a board
|
|
8
|
+
* bigger than that never fully ranked. A Workflow step has unlimited wall-clock and does not burn CPU
|
|
9
|
+
* while awaiting D1 (all our work is D1 I/O), so a board of any size ranks across a series of bounded,
|
|
10
|
+
* individually-durable steps. Each step checkpoints the keyset cursor; a crash resumes from the last
|
|
11
|
+
* completed step instead of restarting. The 64k ceiling is gone.
|
|
12
|
+
*
|
|
13
|
+
* At-most-one at a time: a refresh takes a D1 advisory lock before it starts and releases it when done.
|
|
14
|
+
* If a cron fires again while a refresh is still running (a cron faster than the refresh takes), the
|
|
15
|
+
* second instance cannot take the lock and skips — so two passes never interleave their chunked writes
|
|
16
|
+
* into an incoherent rank set. A crashed instance's lock ages out (see `lock.ts`) so the next fire
|
|
17
|
+
* reclaims it.
|
|
18
|
+
*
|
|
19
|
+
* Why it does NOT require a dedicated worker: this is a Workflow class plus a `scheduled()` handler plus
|
|
20
|
+
* one cron trigger. `pithy add leaderboard` deploys it as its own small worker by default (the template
|
|
21
|
+
* in `wrangler.jsonc`), but the same three pieces can be merged into the adopter's app worker — the
|
|
22
|
+
* capability contributes the Workflow through its manifest. A cron trigger is the one hard requirement;
|
|
23
|
+
* a separate worker is not.
|
|
24
|
+
*
|
|
25
|
+
* Cost: on Workers Paid this stays inside the free allowances at any realistic cadence — a run is a
|
|
26
|
+
* handful of steps (one prune + a few per board), storage is zero (all state is D1), and idle/awaiting
|
|
27
|
+
* steps incur no CPU. Even firing every minute is well under the 500k-steps/month and 10M-requests/month
|
|
28
|
+
* included tiers.
|
|
29
|
+
*/
|
|
30
|
+
interface RankWorkerEnv {
|
|
31
|
+
DB: D1Database;
|
|
32
|
+
/** The resolved leaderboard config, as JSON. The board set is config; this worker needs the same one. */
|
|
33
|
+
LEADERBOARD_CONFIG: string;
|
|
34
|
+
/** Optional override for how long a held refresh lock stays valid before it is reclaimed (see lock.ts). */
|
|
35
|
+
LEADERBOARD_LOCK_STALE_MS?: string;
|
|
36
|
+
/** The Workflow binding — this worker's own class, used by `scheduled()` to start an instance. */
|
|
37
|
+
RANK_REFRESH: {
|
|
38
|
+
create(options?: {
|
|
39
|
+
id?: string;
|
|
40
|
+
}): Promise<unknown>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export declare class RankRefreshWorkflow extends WorkflowEntrypoint<RankWorkerEnv, unknown> {
|
|
44
|
+
run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
declare const _default: {
|
|
47
|
+
/**
|
|
48
|
+
* Cron entry: start one rank-refresh Workflow instance per fire. Each instance re-ranks from the top;
|
|
49
|
+
* the checkpointing is within an instance (resume on crash), not across fires (a fire is a fresh full
|
|
50
|
+
* refresh). If a fire lands while a previous refresh is still running, the new instance takes no lock
|
|
51
|
+
* and exits immediately, so overlapping instances never write concurrently — see the lock acquisition
|
|
52
|
+
* in `run()` above.
|
|
53
|
+
*/
|
|
54
|
+
scheduled(_controller: unknown, env: RankWorkerEnv): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
export default _default;
|
|
57
|
+
//# sourceMappingURL=worker.entry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.entry.d.ts","sourceRoot":"","sources":["../../src/rank/worker.entry.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAW5D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,UAAU,aAAa;IACrB,EAAE,EAAE,UAAU,CAAC;IACf,yGAAyG;IACzG,kBAAkB,EAAE,MAAM,CAAC;IAC3B,2GAA2G;IAC3G,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,kGAAkG;IAClG,YAAY,EAAE;QAAE,MAAM,CAAC,OAAO,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,CAAC;CACvE;AAED,qBAAa,mBAAoB,SAAQ,kBAAkB,CAAC,aAAa,EAAE,OAAO,CAAC;IAClE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA+DpF;CACF;;IAGC;;;;;;OAMG;IACG,SAAS,cAAc,OAAO,OAAO,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { windowKeyAt } from "../window/schedule.js";
|
|
2
|
+
import { LeaderboardConfig } from "../config/config.js";
|
|
3
|
+
import { leaderboardDatabase } from "../data/tables.js";
|
|
4
|
+
import { acquireRefreshLock, releaseRefreshLock } from "./lock.js";
|
|
5
|
+
import { REFRESH_BATCH_CHUNKS, refreshWindowRanks } from "./materialize.js";
|
|
6
|
+
import { leaderboardWorkflowRetry } from "./retryPolicy.js";
|
|
7
|
+
import { pruneBoards } from "../retention/prune.js";
|
|
8
|
+
import { materializedBoards } from "./worker.js";
|
|
9
|
+
import { WorkflowEntrypoint } from "cloudflare:workers";
|
|
10
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
11
|
+
import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
|
|
12
|
+
//#region src/rank/worker.entry.ts
|
|
13
|
+
var RankRefreshWorkflow = class extends WorkflowEntrypoint {
|
|
14
|
+
async run(_event, step) {
|
|
15
|
+
const steps = classifiedSteps(step, leaderboardWorkflowRetry, NonRetryableError);
|
|
16
|
+
const config = LeaderboardConfig.parse(JSON.parse(this.env.LEADERBOARD_CONFIG));
|
|
17
|
+
const db = leaderboardDatabase(this.env.DB);
|
|
18
|
+
const staleMs = this.env.LEADERBOARD_LOCK_STALE_MS ? Number(this.env.LEADERBOARD_LOCK_STALE_MS) : void 0;
|
|
19
|
+
const ctx = await steps.do("refresh-context", async () => ({
|
|
20
|
+
holder: crypto.randomUUID(),
|
|
21
|
+
nowMs: Date.now()
|
|
22
|
+
}));
|
|
23
|
+
const now = new Date(ctx.nowMs);
|
|
24
|
+
if (!await acquireRefreshLock(db, ctx.holder, now, staleMs)) return;
|
|
25
|
+
try {
|
|
26
|
+
await steps.do("prune", () => pruneBoards(db, config.boards, now));
|
|
27
|
+
for (const board of materializedBoards(config)) {
|
|
28
|
+
const window = windowKeyAt(board.window, now);
|
|
29
|
+
let cursor = null;
|
|
30
|
+
let startRank = 0;
|
|
31
|
+
let batch = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const from = cursor ?? void 0;
|
|
34
|
+
const result = await steps.do(`refresh:${board.key}:${batch}`, () => refreshWindowRanks(db, board, window, {
|
|
35
|
+
maxChunks: REFRESH_BATCH_CHUNKS,
|
|
36
|
+
resumeAfter: from,
|
|
37
|
+
startRank
|
|
38
|
+
}));
|
|
39
|
+
if (result.complete) break;
|
|
40
|
+
cursor = result.cursor;
|
|
41
|
+
startRank = result.ranked;
|
|
42
|
+
batch += 1;
|
|
43
|
+
if (!cursor) break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
await releaseRefreshLock(db, ctx.holder);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var worker_entry_default = {
|
|
52
|
+
/**
|
|
53
|
+
* Cron entry: start one rank-refresh Workflow instance per fire. Each instance re-ranks from the top;
|
|
54
|
+
* the checkpointing is within an instance (resume on crash), not across fires (a fire is a fresh full
|
|
55
|
+
* refresh). If a fire lands while a previous refresh is still running, the new instance takes no lock
|
|
56
|
+
* and exits immediately, so overlapping instances never write concurrently — see the lock acquisition
|
|
57
|
+
* in `run()` above.
|
|
58
|
+
*/
|
|
59
|
+
async scheduled(_controller, env) {
|
|
60
|
+
await env.RANK_REFRESH.create();
|
|
61
|
+
} };
|
|
62
|
+
//#endregion
|
|
63
|
+
export { RankRefreshWorkflow, worker_entry_default as default };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { windowKeyAt } from "../window/schedule.js";
|
|
2
|
+
import { materializeSchedule } from "../config/config.js";
|
|
3
|
+
import { leaderboardDatabase } from "../data/tables.js";
|
|
4
|
+
import { refreshWindowRanks } from "./materialize.js";
|
|
5
|
+
import { pruneBoards } from "../retention/prune.js";
|
|
6
|
+
//#region src/rank/worker.ts
|
|
7
|
+
/** The boards a refresh pass must rank: only materialized ones. Live boards compute rank per request. */
|
|
8
|
+
function materializedBoards(config) {
|
|
9
|
+
return materializeSchedule(config) === void 0 ? [] : config.boards;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* **Every contributor here is degraded, and none of them is load-bearing (#371).**
|
|
13
|
+
*
|
|
14
|
+
* The sweep runs first so the refresh never spends a chunk ranking rows about to be deleted — an
|
|
15
|
+
* efficiency, not a precondition. A board whose prune throws keeps its old rows a while longer and ranks
|
|
16
|
+
* correctly regardless, so the sweep failing is no reason to leave every board's ranks stale. And boards
|
|
17
|
+
* are independent of each other by construction: one board's entries, windows and ranks are its own, so a
|
|
18
|
+
* board that will not rank has no claim on any other board's pass.
|
|
19
|
+
*
|
|
20
|
+
* So one sick board costs its own line. What it must never do is read as a board that ranked nobody.
|
|
21
|
+
*/
|
|
22
|
+
async function runRankPass(d1, config, now) {
|
|
23
|
+
const db = leaderboardDatabase(d1);
|
|
24
|
+
const pruned = await pruneBoards(db, config.boards, now);
|
|
25
|
+
const refreshed = [];
|
|
26
|
+
for (const board of materializedBoards(config)) {
|
|
27
|
+
const window = windowKeyAt(board.window, now);
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await refreshWindowRanks(db, board, window);
|
|
31
|
+
} catch {
|
|
32
|
+
refreshed.push({
|
|
33
|
+
state: "unavailable",
|
|
34
|
+
board: board.key,
|
|
35
|
+
window
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
refreshed.push({
|
|
40
|
+
state: "refreshed",
|
|
41
|
+
board: board.key,
|
|
42
|
+
window,
|
|
43
|
+
...result
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
pruned,
|
|
48
|
+
refreshed
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { materializedBoards, runRankPass };
|