The app machine never generates the day's field: the nightly runs on slate and pushes it. Removed the supervised prepare child, its config (MOSAIC_DAILY_PREPARE/_UTC/_JOBS/_BAND/_RINGS, MOSAIC_GOMEMLIMIT on the app), the prepared source and status flag, the image's MOSAIC_ROLE branch and MOSAIC_NIGHTLY_ARGS. push-day reads the token from MOSAIC_PUSH_TOKEN only. Kept: the emergency field, /push and push-day, /status.tomorrow.pushed.
- commit
- 360473c4ec380a76d3e0619c4a2da827097ee5cb
- parent
- cdba7498b1e4800bfcc49b0070cddd8f4e27ff14
- author
- dami <gazey55@gmail.com>
| M | Dockerfile | 5 | +- |
| M | fly.toml | 12 | +-- |
| M | site/ | 179 | +++--------------------------- |
| M | site/ | 2 | +- |
| M | site/ | 19 | +-- |
| M | site/ | 43 | +------- |
@@ -32,7 +32,4 @@ VOLUME ["/data"]
3232 EXPOSE 80803333 # warm the module cache so the first request is not a cold compile3434 RUN deno cache _fresh/server.js || true35-# MOSAIC_ROLE=nightly turns the same image into the scheduled push job:36-# generate tomorrow's field and POST it to the app (MOSAIC_NIGHTLY_ARGS37-# carries --host/--rings/--jobs/--band); anything else serves the site.38-CMD ["sh", "-c", "if [ \"$MOSAIC_ROLE\" = nightly ]; then exec deno task push-day --tomorrow $MOSAIC_NIGHTLY_ARGS; fi; mkdir -p /data && exec deno serve -A --host 0.0.0.0 --port ${PORT} _fresh/server.js"]35+CMD ["sh", "-c", "mkdir -p /data && exec deno serve -A --host 0.0.0.0 --port ${PORT} _fresh/server.js"]@@ -10,17 +10,9 @@ primary_region = "dfw"
1010 dockerfile = 'Dockerfile'1111 1212 [env]13- # the nightly prepare runs here: tomorrow's field from 20:00 UTC,14- # killed at rollover if it overruns; a pushed field is the fallback15- # generation here is the emergency fallback only; the nightly runs on16- # the scheduled machine mosaic-nightly (performance-4x) and pushes13+ # the day's field is pushed by the nightly on slate; this box generates14+ # only the small emergency field when a day has nothing pushed1715 MOSAIC_GENERATE = '1'18- MOSAIC_DAILY_PREPARE = '0'19- MOSAIC_DAILY_PREPARE_UTC = '20'20- MOSAIC_DAILY_JOBS = '2'21- MOSAIC_DAILY_BAND = '24'22- MOSAIC_GOMEMLIMIT = '256MiB'23- MOSAIC_DAILY_RINGS = 'all'2416 MOSAIC_DAILY = '1'2517 MOSAIC_DAILY_GROW_MINUTES = '60'2618 MOSAIC_DATA = '/data/'@@ -1,5 +1,5 @@
11 // The daily loop: one field per date. The day's seed is derived from the2-// date; its rings are generated ahead (the night before, cached), so2+// date; its rings are generated the night before on slate and pushed, so33 // growth by day is reveal; at rollover the game is ended, archived under44 // data/days/<date>/ and indexed, and the next day's game starts with55 // yesterday's top user honoured (crown, reserved hue and seat) and the@@ -19,7 +19,6 @@ import {
1919 type FieldOutput,2020 gen,2121 type GenParams,22- isCached,2322 judge,2423 setEngineLog,2524 unwrapField,@@ -39,22 +38,13 @@ export type DailyConfig = {
3938 chunks: number;4039 chunk_g: number;4140 G: number;42- rings: number | "all";4341 grow_minutes: number;4442 rollover_hour_utc: number; // the day changes at this UTC hour4543 fog: boolean;46- /** run the engine on this box (fields can also be pushed) */44+ /** run the engine on this box for the emergency field (the day's45+ * field itself is pushed by the nightly) */4746 generate: boolean;48- /** run the nightly prepare on this box; off when a scheduled machine49- * pushes tomorrow's field (the app machine then only serves, and50- * generates nothing but an emergency field) */51- prepare: boolean;52- /** the nightly prepare of tomorrow's field starts at this UTC hour and53- * must finish by rollover (its deadline); jobs = chunk processes at once */54- prepare_hour_utc: number;55- jobs: number;56- band: number;57- /** the emergency field generated at rollover when nothing is ready */47+ /** the emergency field generated at rollover when nothing is pushed */5848 emergency: { chunks: number; chunk_g: number; rings: number };5949 };6050 @@ -63,23 +53,15 @@ export function configFromEnv(): DailyConfig {
6353 const v = Number(Deno.env.get(k));6454 return Number.isFinite(v) && v > 0 ? v : d;6555 };66- const ringsEnv = Deno.env.get("MOSAIC_DAILY_RINGS");6756 return {6857 namespace: Deno.env.get("MOSAIC_DAILY_NAMESPACE") ?? "mosaic-daily",6958 chunks: num("MOSAIC_DAILY_CHUNKS", 3),7059 chunk_g: num("MOSAIC_DAILY_CHUNK_G", 60),7160 G: num("MOSAIC_DAILY_G", 1000),72- rings: ringsEnv && ringsEnv !== "all"73- ? num("MOSAIC_DAILY_RINGS", 2)74- : "all",7561 grow_minutes: num("MOSAIC_DAILY_GROW_MINUTES", 60),7662 rollover_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_ROLLOVER_UTC") ?? 0),7763 fog: (Deno.env.get("MOSAIC_DAILY_FOG") ?? "1") !== "0",7864 generate: Deno.env.get("MOSAIC_GENERATE") === "1",79- prepare: (Deno.env.get("MOSAIC_DAILY_PREPARE") ?? "1") !== "0",80- prepare_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_PREPARE_UTC") ?? 20),81- jobs: num("MOSAIC_DAILY_JOBS", 2),82- band: num("MOSAIC_DAILY_BAND", 24),8365 emergency: { chunks: 3, chunk_g: 60, rings: 1 },8466 };8567 }@@ -96,19 +78,6 @@ export function nextRollover(nowMs: number, cfg: DailyConfig): number {
9678 return today > nowMs ? today : today + 24 * 3600 * 1000;9779 }9880 99-/** When the nightly prepare for the field after `nowMs` starts: the100- * prepare hour of the current (rollover-shifted) day, or now if that101- * has passed. */102-export function prepareAt(nowMs: number, cfg: DailyConfig): number {103- const roll = nextRollover(nowMs, cfg);104- // the day that rolls over at `roll` started 24 h earlier105- const dayStart = roll - 24 * 3600 * 1000;106- const at = dayStart +107- (((cfg.prepare_hour_utc - cfg.rollover_hour_utc) % 24 + 24) % 24) *108- 3600 * 1000;109- return at;110-}111-11281 /** Where pushed fields live: <data>/fields/<date>.json, the engine's11382 * `field --rings` output (base + rings) as produced by `push-day`. */11483 export function fieldsDir(dir: string): string {@@ -175,7 +144,7 @@ export function paramsFor(
175144 chunk_g: cfg.chunk_g,176145 field_g: cfg.G,177146 seed,178- rings: cfg.rings,147+ rings: "all",179148 grow: { every_minutes: cfg.grow_minutes },180149 rules: { fog: cfg.fog },181150 pinned: true,@@ -294,8 +263,8 @@ export type DailyStatus = {
294263 since_s: number | null; // seconds spent generating so far295264 emergency: boolean;296265 latest_archived: { date: string; game_id: string } | null;297- /** tomorrow's field: pushed to this box, or prepared in its cache */298- tomorrow: { date: string; pushed: boolean; prepared: boolean };266+ /** tomorrow's field, pushed to this box by the nightly */267+ tomorrow: { date: string; pushed: boolean };299268 };300269 301270 export type DailyState = {@@ -303,7 +272,7 @@ export type DailyState = {
303272 game_id: string;304273 seed: string;305274 /** where the day's field came from; resume reads the same source */306- source: "prepared" | "pushed" | "emergency";275+ source: "pushed" | "emergency";307276 };308277 309278 type Opts = {@@ -332,13 +301,8 @@ export class Daily {
332301 /** the date whose field is a small emergency one, generated at rollover */333302 emergency: string | null = null;334303 /** today's field being produced right now (emergency generation or a335- * prepared/pushed one loading), with when that started */304+ * pushed one loading), with when that started */336305 generating: { date: string; since: number } | null = null;337- private preparing: string | null = null;338- private prepared = new Set<string>();339- private preparedSeeds = new Set<string>();340- private failedPrepare = new Set<string>();341- private lastPrepareLog = "";342306 private timer: number | null = null;343307 private ticking: Promise<void> | null = null;344308 index: DaysIndex;@@ -378,39 +342,24 @@ export class Daily {
378342 chunk_g: chunkG,379343 field_g: this.cfg.G,380344 rings,381- jobs: this.cfg.jobs,382- band: this.cfg.band,383345 };384346 }385347 386348 /**387- * The field for a date, in the order that always yields one when this388- * box may generate: the prepared field (in the cache from the nightly389- * job), else a pushed file, else a small emergency field generated now390- * (3x3, one ring, under a short budget), which is said on `/`.349+ * The field for a date: the pushed one (made by the nightly on slate),350+ * else, when this box may generate, a small emergency field generated351+ * now (3x3, one ring, under a short budget), which is said on `/`.391352 */392353 private async fieldFor(393354 date: string,394355 seed: string,395356 ): Promise<{ out: FieldOutput; source: DailyState["source"] } | null> {396- // a pushed field wins: it was made by a machine that could afford397- // the whole thing; the on-box prepare is the fallback for it398357 const pushed = await loadPushed(this.dir, date);399358 if (pushed) return { out: pushed, source: "pushed" };400- const full = this.genParams(401- date,402- seed,403- this.cfg.rings,404- this.cfg.chunks,405- this.cfg.chunk_g,406- );407- if (this.canGenerate && await this.isPrepared(full)) {408- return { out: await this.generate(full), source: "prepared" };409- }410359 if (!this.canGenerate) return null;411360 const e = this.cfg.emergency;412361 this.log(413- `${date}: nothing prepared or pushed; generating an emergency field (${e.chunks}x${e.chunks} of ${e.chunk_g}, ${e.rings} ring)`,362+ `${date}: nothing pushed; generating an emergency field (${e.chunks}x${e.chunks} of ${e.chunk_g}, ${e.rings} ring)`,414363 );415364 const t0 = this.now();416365 const out = await this.generate(this.emergencyParams(date, seed), {@@ -429,16 +378,9 @@ export class Daily {
429378 return this.genParams(date, seed, e.rings, e.chunks, e.chunk_g);430379 }431380 432- /** Whether the full field for these params is in the cache (tests'433- * generators are synchronous stand-ins: treat them as prepared). */434- private async isPrepared(p: GenParams): Promise<boolean> {435- if (this.generate !== gen) return this.preparedSeeds.has(p.seed);436- return await isCached(p);437- }438-439381 /** A generator for the registry that returns the field this date440- * started from, whatever params the registry passes: the prepared441- * cache, the pushed file, or the emergency field (all cache hits). */382+ * started from, whatever params the registry passes: the pushed file383+ * or the emergency field (a cache hit). */442384 private generatorFor(443385 date: string,444386 seed: string,@@ -452,18 +394,7 @@ export class Daily {
452394 if (!pushed) throw new Error(`pushed field for ${date} is gone`);453395 return pushed;454396 }455- if (source === "emergency") {456- return await this.generate(this.emergencyParams(date, seed));457- }458- return await this.generate(459- this.genParams(460- date,461- seed,462- this.cfg.rings,463- this.cfg.chunks,464- this.cfg.chunk_g,465- ),466- );397+ return await this.generate(this.emergencyParams(date, seed));467398 };468399 }469400 @@ -498,7 +429,7 @@ export class Daily {
498429 const ref = await resumeGame(499430 cur.game_id,500431 p,501- this.generatorFor(cur.date, cur.seed, cur.source ?? "prepared"),432+ this.generatorFor(cur.date, cur.seed, cur.source ?? "pushed"),502433 this.now,503434 this.judgeFn,504435 );@@ -528,7 +459,7 @@ export class Daily {
528459 }529460 530461 /** One scheduler step (serialised): rollover if the date changed, start531- * today's game if none, run the nightly prepare in its window. */462+ * today's game if none. */532463 tick(): Promise<void> {533464 if (this.ticking) return this.ticking;534465 this.ticking = this.tickOnce().finally(() => {@@ -546,73 +477,6 @@ export class Daily {
546477 if (!this.current || !getGame(this.current.game_id)) {547478 await this.startDay(today);548479 }549- if (this.canGenerate && this.cfg.prepare) this.maybePrepare(today);550- else if (this.lastPrepareLog !== "external") {551- this.lastPrepareLog = "external";552- this.log(553- "nightly prepare is external: tomorrow's field is pushed by the nightly machine; this box generates only an emergency field",554- );555- }556- }557-558- /** The nightly job: from the prepare hour, generate tomorrow's field559- * with the rest of the day as budget (killed at rollover). Logged once560- * per state. */561- private maybePrepare(today: string): void {562- const tomorrow = nextDate(today);563- const now = this.now();564- const at = prepareAt(now, this.cfg);565- const deadline = nextRollover(now, this.cfg);566- if (567- this.prepared.has(tomorrow) || this.failedPrepare.has(tomorrow) ||568- this.preparing569- ) return;570- if (now < at) {571- const msg = `next prepare: ${tomorrow} at ${572- new Date(at).toISOString()573- } (deadline ${574- new Date(deadline).toISOString()575- }, jobs ${this.cfg.jobs}, rings ${this.cfg.rings})`;576- if (msg !== this.lastPrepareLog) {577- this.lastPrepareLog = msg;578- this.log(msg);579- }580- return;581- }582- this.prepare(tomorrow, Math.max(60_000, deadline - now - 5 * 60_000));583- }584-585- /** Generate a day's field ahead of time (cached; idempotent), under a586- * budget; a run that overruns is killed and logged. */587- prepare(date: string, budgetMs: number): void {588- if (this.prepared.has(date) || this.preparing === date) return;589- this.preparing = date;590- (async () => {591- const seed = await seedFor(date, this.cfg);592- const params = this.genParams(593- date,594- seed,595- this.cfg.rings,596- this.cfg.chunks,597- this.cfg.chunk_g,598- );599- params.timeout_s = Math.floor(budgetMs / 1000);600- const t0 = this.now();601- this.log(602- `prepare ${date}: started, budget ${603- Math.round(budgetMs / 60000)604- } min, jobs ${this.cfg.jobs}, rings ${this.cfg.rings}`,605- );606- await this.generate(params, { timeoutMs: budgetMs });607- this.prepared.add(date);608- this.preparedSeeds.add(seed);609- this.log(`prepared ${date} in ${Math.round((this.now() - t0) / 1000)} s`);610- })().catch((e) => {611- this.failedPrepare.add(date);612- this.log(`prepare ${date} failed: ${e}`);613- }).finally(() => {614- if (this.preparing === date) this.preparing = null;615- });616480 }617481 618482 /** Honours for a day: the previous day's top user, seated on the new@@ -655,7 +519,6 @@ export class Daily {
655519 const { out, source } = field;656520 this.missing = null;657521 this.emergency = source === "emergency" ? date : null;658- this.prepared.add(date);659522 const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };660523 const seat = m.seed_tiles?.[0] ?? m.seed_tile;661524 const honours = this.honoursFor(date, seat);@@ -779,11 +642,7 @@ export class Daily {
779642 latest_archived: latest780643 ? { date: latest.date, game_id: latest.game_id }781644 : null,782- tomorrow: {783- date: tomorrow,784- pushed,785- prepared: this.prepared.has(tomorrow),786- },645+ tomorrow: { date: tomorrow, pushed },787646 };788647 }789648 @@ -15,7 +15,7 @@ export const handler = define.handlers({
1515 since_s: null,1616 emergency: false,1717 latest_archived: null,18- tomorrow: { date: "", pushed: false, prepared: false },18+ tomorrow: { date: "", pushed: false },1919 }, { headers: { "Cache-Control": "no-store" } });2020 }2121 return Response.json(d.status(), {@@ -4,7 +4,7 @@
44 // [--chunks 3] [--chunk-g 60] [--G 1000] [--rings 2] [--namespace mosaic-daily]55 // deno task push-day --tomorrow ... (the nightly machine: tomorrow, UTC)66 //7-// The token comes from MOSAIC_PUSH_TOKEN or ~/.config/mosaic/push-token.7+// The token comes from MOSAIC_PUSH_TOKEN.88 // The field parameters must match the hosted daily's configuration (its99 // seed is derived from the namespace and the date, so the same date1010 // always yields the same field).@@ -42,19 +42,10 @@ const cfg = {
4242 band: Number(args.band ?? 24),4343 emergency: { chunks: 3, chunk_g: 60, rings: 1 },4444 };45-async function token(): Promise<string> {45+function token(): string {4646 const env = Deno.env.get("MOSAIC_PUSH_TOKEN");47- if (env) return env;48- const home = Deno.env.get("HOME") ?? "";49- try {50- return (await Deno.readTextFile(`${home}/.config/mosaic/push-token`))51- .trim();52- } catch {53- console.error(54- "no MOSAIC_PUSH_TOKEN in the environment and no ~/.config/mosaic/push-token",55- );56- Deno.exit(2);57- }47+ if (env && env.trim()) return env.trim();48+ throw new Error("no MOSAIC_PUSH_TOKEN in the environment");5849 }5950 const seed = await seedFor(date, cfg);6051 console.log(@@ -84,7 +75,7 @@ console.log(
8475 const res = await fetch(`${host}/push/${date}`, {8576 method: "POST",8677 headers: {87- "Authorization": `Bearer ${await token()}`,78+ "Authorization": `Bearer ${token()}`,8879 "Content-Type": "application/json",8980 },9081 body,@@ -27,15 +27,10 @@ const cfg: DailyConfig = {
2727 chunks: 1,2828 chunk_g: 60,2929 G: 140,30- rings: 0,3130 grow_minutes: 1440, // never during the test3231 rollover_hour_utc: 0,3332 fog: true,3433 generate: false, // the injected generator stands in for a pushed field35- prepare: true,36- prepare_hour_utc: 20,37- jobs: 1,38- band: 16,3934 emergency: { chunks: 1, chunk_g: 40, rings: 1 },4035 };4136 @@ -235,14 +230,12 @@ Deno.test("a day with nothing pushed and generation off is missing; a push start
235230 resetGames();236231 });237232 238-Deno.test("nightly prepare waits for its hour, and rollover with nothing ready makes an emergency field", async () => {233+Deno.test("rollover with nothing pushed makes an emergency field and says so", async () => {239234 resetGames();240235 const dir = await Deno.makeTempDir() + "/";241- let now = Date.parse("2026-11-01T10:00:00Z");236+ const now = Date.parse("2026-11-01T10:00:00Z");242237 const logs: string[] = [];243238 const calls: GenParams[] = [];244- // a generator that records every call; generation "allowed" because245- // it is injected (the box's own generator would be gen)246239 const recording = (p: GenParams) => {247240 calls.push(p);248241 return generate(p);@@ -256,44 +249,14 @@ Deno.test("nightly prepare waits for its hour, and rollover with nothing ready m
256249 log: (m) => logs.push(m),257250 });258251 await d.boot();259- // nothing prepared or pushed for today: an emergency field, said so260252 assert(d.current && d.current.date === "2026-11-01");261253 assertEquals(d.current!.source, "emergency");262254 assertEquals(d.emergency, "2026-11-01");263255 assertEquals(d.status().phase, "live");264256 assertEquals(d.status().emergency, true);257+ assertEquals(d.status().tomorrow, { date: "2026-11-02", pushed: false });265258 assertEquals(calls.at(-1)!.rings, cfg.emergency.rings);266259 assert(logs.some((m) => m.includes("emergency field")));267- // before 20:00 UTC the nightly prepare only announces itself268- const before = calls.length;269- await d.tick();270- assertEquals(calls.length, before);271- assert(272- logs.some((m) =>273- m.startsWith("next prepare: 2026-11-02 at 2026-11-01T20:00:00")274- ),275- );276- // at 20:00 it runs, with the rest of the day as budget277- now = Date.parse("2026-11-01T20:00:30Z");278- await d.tick();279- await new Promise((r) => setTimeout(r, 20));280- const prep = calls.at(-1)!;281- assertEquals(prep.rings, cfg.rings);282- assertEquals(283- prep.seed,284- await (await import("../lib/daily.ts")).seedFor("2026-11-02", cfg),285- );286- assert(287- prep.timeout_s! > 3 * 3600 && prep.timeout_s! < 4 * 3600,288- `budget ${prep.timeout_s}`,289- );290- assert(logs.some((m) => m.startsWith("prepared 2026-11-02")));291- // rollover: the prepared field is used, not an emergency one292- now = Date.parse("2026-11-02T00:05:00Z");293- await d.tick();294- assertEquals(d.current!.date, "2026-11-02");295- assertEquals(d.current!.source, "prepared");296- assertEquals(d.emergency, null);297260 d.stop();298261 resetGames();299262 });