Skip to content

Commit c0ea3ef

Browse files
committed
feat(demo): anonymous concurrency hammer on landing page
- new slices/demo: hammerRun fires N parallel writes at a sentinel DEMO_SHEET_ID through the real submitWrite pipeline - dedicated demo-processor loop in apps/api with a noop sink; reuses the advisory-lock + ledger guarantees from slices/write-queue - POST /v1/demo/hammer (per-IP rate limited, 5 runs/hour, cap 50) and GET /v1/demo/hammer/:runId return arrival-ordered ledger rows - <HammerDemo /> on / renders a 50-tile grid that fills in serialized order, proving the thesis in one click without auth
1 parent 6d263d5 commit c0ea3ef

17 files changed

Lines changed: 593 additions & 3 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@sheetforge/shared-redis": "workspace:^",
2222
"@sheetforge/shared-types": "workspace:^",
2323
"@sheetforge/slice-auth": "workspace:^",
24+
"@sheetforge/slice-demo": "workspace:^",
2425
"@sheetforge/slice-projects": "workspace:^",
2526
"@sheetforge/slice-rest-api": "workspace:^",
2627
"@sheetforge/slice-schema": "workspace:^",

apps/api/src/demo-processor.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { QueueRedisClient } from '@sheetforge/queue';
2+
import type { Db } from '@sheetforge/shared-db';
3+
import { createLogger } from '@sheetforge/shared-logger';
4+
import { DEMO_SHEET_ID } from '@sheetforge/slice-demo';
5+
import { processNext, streamKeyForSheet } from '@sheetforge/slice-write-queue';
6+
7+
const log = createLogger({ service: 'demo-processor' });
8+
const GROUP = 'acid-workers';
9+
const CONSUMER = 'api-demo';
10+
11+
/**
12+
* Dedicated processor loop for the public hammer demo. Same advisory-lock and
13+
* ledger path as the real pipeline — the only difference is the handler: we
14+
* skip the Google Sheets API call entirely because the "demo sheet" is a
15+
* sentinel that has no real spreadsheet behind it. The fencing, ordering and
16+
* idempotency guarantees are what visitors see anyway.
17+
*/
18+
export async function demoProcessorTick({
19+
db,
20+
redis,
21+
}: {
22+
db: Db;
23+
redis: QueueRedisClient;
24+
}): Promise<void> {
25+
const streamKey = streamKeyForSheet(DEMO_SHEET_ID);
26+
await processNext({
27+
db,
28+
redis,
29+
streamKey,
30+
group: GROUP,
31+
consumer: CONSUMER,
32+
blockMs: 100,
33+
handler: async (msg) => {
34+
// Synthetic sink — nothing to do. The advisory lock + ledger writes
35+
// around this handler are the whole point.
36+
log.debug({ writeId: msg.writeId }, 'demo-write-processed');
37+
},
38+
});
39+
}

apps/api/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sheetforge/shared-logger';
33
import { createIoredisQueueClient } from '@sheetforge/shared-redis';
44
import { createRouter } from '@sheetforge/slice-rest-api';
55
import { serve } from '@hono/node-server';
6+
import { demoProcessorTick } from './demo-processor.js';
67
import { loadEnv } from './env.js';
78
import { processorTick } from './processor.js';
89

@@ -44,6 +45,23 @@ if (env.PROCESSOR_ENABLED) {
4445
await new Promise((res) => setTimeout(res, env.PROCESSOR_TICK_MS));
4546
}
4647
})();
48+
49+
// Dedicated drain for the public hammer demo stream. Runs on its own loop
50+
// so the demo doesn't get starved (or starve) the real processor.
51+
(async () => {
52+
log.info({}, 'demo-processor-starting');
53+
while (true) {
54+
try {
55+
await demoProcessorTick({ db, redis });
56+
} catch (err) {
57+
log.error(
58+
{ err: err instanceof Error ? err.message : String(err) },
59+
'demo-processor-tick-failed',
60+
);
61+
}
62+
// No sleep — processNext blocks on BLOCK 100ms when idle anyway.
63+
}
64+
})();
4765
}
4866

4967
process.on('SIGTERM', () => {

apps/web/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4+
import { HammerDemo } from "@/components/HammerDemo";
45
import { ChevronDownIcon, CopyIcon, OpenCodeLogo } from "@/components/icons";
56
import { getMe } from "@/lib/api-client";
67

@@ -406,6 +407,7 @@ export default function Home() {
406407
>
407408
<Header authStatus={authStatus} />
408409
<HeroSection authStatus={authStatus} />
410+
<HammerDemo />
409411
<FeaturesSection />
410412
<StatsSection />
411413
<PrivacySection />
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import {
5+
ApiError,
6+
type HammerStatus,
7+
type HammerWrite,
8+
getHammerStatus,
9+
hammerRun,
10+
} from "@/lib/api-client";
11+
12+
const TOTAL = 50;
13+
14+
type Phase = "idle" | "dispatching" | "watching" | "done" | "error";
15+
16+
export function HammerDemo() {
17+
const [phase, setPhase] = useState<Phase>("idle");
18+
const [runId, setRunId] = useState<string | null>(null);
19+
const [dispatchedAt, setDispatchedAt] = useState<number | null>(null);
20+
const [dispatchMs, setDispatchMs] = useState<number | null>(null);
21+
const [status, setStatus] = useState<HammerStatus | null>(null);
22+
const [error, setError] = useState<string | null>(null);
23+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
24+
25+
const stopPolling = useCallback(() => {
26+
if (pollRef.current) {
27+
clearInterval(pollRef.current);
28+
pollRef.current = null;
29+
}
30+
}, []);
31+
32+
useEffect(() => stopPolling, [stopPolling]);
33+
34+
const start = useCallback(async () => {
35+
setError(null);
36+
setStatus(null);
37+
setRunId(null);
38+
setDispatchMs(null);
39+
setPhase("dispatching");
40+
const t0 = performance.now();
41+
try {
42+
const result = await hammerRun(TOTAL);
43+
const t1 = performance.now();
44+
setRunId(result.runId);
45+
setDispatchedAt(Date.parse(result.dispatchedAt));
46+
setDispatchMs(Math.round(t1 - t0));
47+
setPhase("watching");
48+
} catch (err) {
49+
setPhase("error");
50+
if (err instanceof ApiError) {
51+
setError(err.message);
52+
} else if (err instanceof Error) {
53+
setError(err.message);
54+
} else {
55+
setError("request failed");
56+
}
57+
}
58+
}, []);
59+
60+
useEffect(() => {
61+
if (phase !== "watching" || !runId) return;
62+
let cancelled = false;
63+
const poll = async () => {
64+
try {
65+
const next = await getHammerStatus(runId);
66+
if (cancelled) return;
67+
setStatus(next);
68+
if (next.done) {
69+
stopPolling();
70+
setPhase("done");
71+
}
72+
} catch {
73+
// Swallow transient polling errors; next tick retries.
74+
}
75+
};
76+
poll();
77+
pollRef.current = setInterval(poll, 150);
78+
return () => {
79+
cancelled = true;
80+
stopPolling();
81+
};
82+
}, [phase, runId, stopPolling]);
83+
84+
const tiles = buildTiles(status?.writes ?? []);
85+
const completedCount = tiles.filter((t) => t.kind === "filled").length;
86+
const spread = spreadMs(status?.writes ?? []);
87+
88+
return (
89+
<section
90+
className="px-[80px] py-[64px] border-b"
91+
style={{ borderColor: "#3d3838" }}
92+
>
93+
<h3 className="text-[16px] font-bold text-[#f2eded] mb-3">
94+
See it for yourself — hammer the queue
95+
</h3>
96+
<p className="text-[#b8b2b2] mb-6 leading-[24px]">
97+
<span className="text-[#716b6a]">[*]</span> Click the button. We fire{" "}
98+
{TOTAL} parallel writes at a synthetic sheet through the real pipeline —
99+
same advisory lock, same Redis stream, same ledger. Watch them land in
100+
order.
101+
</p>
102+
103+
<div className="flex items-center gap-3 mb-4">
104+
<button
105+
type="button"
106+
onClick={start}
107+
disabled={phase === "dispatching" || phase === "watching"}
108+
className="rounded px-6 py-2 font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
109+
style={{ backgroundColor: "#f2eded", color: "#131010" }}
110+
>
111+
{phase === "dispatching"
112+
? "dispatching…"
113+
: phase === "watching"
114+
? "watching…"
115+
: phase === "done"
116+
? `run again (${TOTAL} writes)`
117+
: `fire ${TOTAL} parallel writes`}
118+
</button>
119+
{dispatchMs !== null && (
120+
<span className="text-xs text-[#7f7a7a]">
121+
dispatched {TOTAL} writes in {dispatchMs}ms
122+
</span>
123+
)}
124+
</div>
125+
126+
{error && (
127+
<p className="text-sm mb-4" style={{ color: "#f87171" }}>
128+
[!] {error}
129+
</p>
130+
)}
131+
132+
<div
133+
className="border rounded p-4"
134+
style={{ borderColor: "#3d3838", backgroundColor: "#131010" }}
135+
>
136+
<div className="grid grid-cols-10 gap-1">
137+
{tiles.map((tile, i) => (
138+
<div
139+
key={i}
140+
className="aspect-square border rounded flex items-center justify-center text-[10px] font-mono"
141+
style={{
142+
borderColor: tile.kind === "filled" ? "#3d3838" : "#2a2626",
143+
backgroundColor:
144+
tile.kind === "filled" ? "#1b1818" : "#131010",
145+
color: tile.kind === "filled" ? "#f2eded" : "#4a4545",
146+
}}
147+
title={
148+
tile.kind === "filled"
149+
? `#${tile.arrival} · ordinal ${tile.ordinal} · ${tile.writeId.slice(0, 8)}`
150+
: "pending"
151+
}
152+
>
153+
{tile.kind === "filled" ? tile.arrival : "·"}
154+
</div>
155+
))}
156+
</div>
157+
158+
<div className="flex items-center justify-between mt-4 text-xs text-[#7f7a7a]">
159+
<span>
160+
{completedCount}/{TOTAL} writes landed
161+
{spread !== null && (
162+
<>
163+
{" "}
164+
· serialized over {spread}ms · zero collisions
165+
</>
166+
)}
167+
</span>
168+
{runId && (
169+
<span>
170+
run <code className="text-[#b8b2b2]">{runId.slice(0, 8)}</code>
171+
</span>
172+
)}
173+
</div>
174+
</div>
175+
176+
<p className="text-xs text-[#7f7a7a] mt-3 leading-[18px]">
177+
Numbers are the arrival order. Dispatched in parallel, processed one at
178+
a time per sheet — that's the whole product.
179+
</p>
180+
</section>
181+
);
182+
}
183+
184+
interface Tile {
185+
kind: "empty" | "filled";
186+
arrival: number;
187+
ordinal: number;
188+
writeId: string;
189+
}
190+
191+
function buildTiles(writes: HammerWrite[]): Tile[] {
192+
const tiles: Tile[] = Array.from({ length: TOTAL }, () => ({
193+
kind: "empty",
194+
arrival: 0,
195+
ordinal: 0,
196+
writeId: "",
197+
}));
198+
const completed = writes.filter((w) => w.completedAt !== null);
199+
completed.forEach((w, i) => {
200+
if (i < TOTAL) {
201+
tiles[i] = {
202+
kind: "filled",
203+
arrival: i + 1,
204+
ordinal: w.ordinal,
205+
writeId: w.writeId,
206+
};
207+
}
208+
});
209+
return tiles;
210+
}
211+
212+
function spreadMs(writes: HammerWrite[]): number | null {
213+
const completed = writes
214+
.filter((w) => w.completedAt !== null)
215+
.map((w) => Date.parse(w.completedAt as string));
216+
if (completed.length < 2) return null;
217+
return Math.max(...completed) - Math.min(...completed);
218+
}

apps/web/src/lib/api-client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,45 @@ export const testWrite = (projectId: string, sheetId: string) =>
221221
{ method: 'POST', body: JSON.stringify({}) },
222222
);
223223

224+
// ---------------------------------------------------------------------------
225+
// Public hammer demo (anonymous, rate-limited)
226+
// ---------------------------------------------------------------------------
227+
228+
export interface HammerRunResult {
229+
runId: string;
230+
n: number;
231+
dispatchedAt: string;
232+
}
233+
234+
export interface HammerWrite {
235+
writeId: string;
236+
ordinal: number;
237+
idempotencyKey: string;
238+
enqueuedAt: string;
239+
completedAt: string | null;
240+
status:
241+
| 'pending'
242+
| 'processing'
243+
| 'completed'
244+
| 'failed'
245+
| 'dead_lettered';
246+
}
247+
248+
export interface HammerStatus {
249+
runId: string;
250+
writes: HammerWrite[];
251+
done: boolean;
252+
}
253+
254+
export const hammerRun = (n: number) =>
255+
api<HammerRunResult>('/v1/demo/hammer', {
256+
method: 'POST',
257+
body: JSON.stringify({ n }),
258+
});
259+
260+
export const getHammerStatus = (runId: string) =>
261+
api<HammerStatus>(`/v1/demo/hammer/${runId}`);
262+
224263
export const logout = () =>
225264
api<{ ok: true }>('/v1/auth/logout', {
226265
method: 'POST',

0 commit comments

Comments
 (0)