@drunkcoding/agents-and-skills 0.0.12 β 0.0.14
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/.claude-plugin/marketplace.json +9 -6
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/html-effectiveness/.claude-plugin/plugin.json +1 -1
- package/plugins/html-effectiveness/README.md +9 -4
- package/plugins/html-effectiveness/agents/report-builder.md +4 -0
- package/plugins/html-effectiveness/assets/base.css +40 -20
- package/plugins/html-effectiveness/docs/template-gallery.md +28 -0
- package/plugins/html-effectiveness/scripts/_tmp.js +37 -0
- package/plugins/html-effectiveness/scripts/catalog.js +47 -0
- package/plugins/html-effectiveness/scripts/extract.js +104 -0
- package/plugins/html-effectiveness/scripts/render.js +2 -2
- package/plugins/html-effectiveness/skills/html-effectiveness/SKILL.md +4 -4
- package/plugins/html-effectiveness/templates/01-exploration-code-approaches.html.tmpl +400 -0
- package/plugins/html-effectiveness/templates/02-exploration-visual-designs.html.tmpl +456 -0
- package/plugins/html-effectiveness/templates/03-code-review-pr.html.tmpl +581 -0
- package/plugins/html-effectiveness/templates/04-code-understanding.html.tmpl +443 -0
- package/plugins/html-effectiveness/templates/05-design-system.html.tmpl +578 -0
- package/plugins/html-effectiveness/templates/06-component-variants.html.tmpl +557 -0
- package/plugins/html-effectiveness/templates/07-prototype-animation.html.tmpl +417 -0
- package/plugins/html-effectiveness/templates/08-prototype-interaction.html.tmpl +363 -0
- package/plugins/html-effectiveness/templates/09-slide-deck.html.tmpl +535 -0
- package/plugins/html-effectiveness/templates/10-svg-illustrations.html.tmpl +444 -0
- package/plugins/html-effectiveness/templates/11-status-report.html.tmpl +152 -0
- package/plugins/html-effectiveness/templates/12-incident-report.html.tmpl +547 -0
- package/plugins/html-effectiveness/templates/13-flowchart-diagram.html.tmpl +361 -0
- package/plugins/html-effectiveness/templates/14-research-feature-explainer.html.tmpl +348 -0
- package/plugins/html-effectiveness/templates/15-research-concept-explainer.html.tmpl +334 -0
- package/plugins/html-effectiveness/templates/16-implementation-plan.html.tmpl +657 -0
- package/plugins/html-effectiveness/templates/17-pr-writeup.html.tmpl +553 -0
- package/plugins/html-effectiveness/templates/18-editor-triage-board.html.tmpl +541 -0
- package/plugins/html-effectiveness/templates/19-editor-feature-flags.html.tmpl +627 -0
- package/plugins/html-effectiveness/templates/20-editor-prompt-tuner.html.tmpl +687 -0
- package/plugins/html-effectiveness/templates/manifest.json +436 -2
- package/plugins/html-effectiveness/tests/_tmp.test.js +58 -0
- package/plugins/html-effectiveness/tests/fixtures/01-exploration-code-approaches.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/02-exploration-visual-designs.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/03-code-review-pr.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/04-code-understanding.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/05-design-system.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/06-component-variants.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/07-prototype-animation.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/08-prototype-interaction.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/09-slide-deck.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/10-svg-illustrations.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/11-status-report.data.json +37 -0
- package/plugins/html-effectiveness/tests/fixtures/12-incident-report.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/13-flowchart-diagram.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/14-research-feature-explainer.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/15-research-concept-explainer.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/16-implementation-plan.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/17-pr-writeup.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/18-editor-triage-board.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/19-editor-feature-flags.data.json +3 -0
- package/plugins/html-effectiveness/tests/fixtures/20-editor-prompt-tuner.data.json +3 -0
- package/plugins/html-effectiveness/tests/manifest.test.js +61 -0
- package/plugins/plugin-validator/.claude-plugin/plugin.json +1 -1
- package/plugins/plugin-validator/skills/validate-agents/SKILL.md +2 -2
- package/plugins/plugin-validator/skills/validate-commands/SKILL.md +2 -2
- package/plugins/plugin-validator/skills/validate-hooks/SKILL.md +57 -18
- package/plugins/team-superpower/.claude-plugin/plugin.json +7 -39
- package/plugins/team-superpower/README.md +73 -19
- package/plugins/team-superpower/agents/backend-developer.md +33 -0
- package/plugins/team-superpower/agents/designer.md +5 -1
- package/plugins/team-superpower/agents/frontend-developer.md +33 -0
- package/plugins/team-superpower/agents/planner.md +31 -11
- package/plugins/team-superpower/agents/qa-engineer.md +30 -0
- package/plugins/team-superpower/agents/reviewer.md +37 -21
- package/plugins/team-superpower/agents/security-engineer.md +30 -0
- package/plugins/team-superpower/agents/software-architect.md +30 -0
- package/plugins/team-superpower/assets/ESCALATION.md +19 -5
- package/plugins/team-superpower/assets/SESSION_README.md +31 -11
- package/plugins/team-superpower/commands/team-feature-resume.md +40 -5
- package/plugins/team-superpower/commands/team-feature.md +148 -22
- package/plugins/team-superpower/hooks/hooks.json +0 -3
- package/plugins/tech-graph/.claude-plugin/plugin.json +1 -1
- package/plugins/tech-graph/agents/tech-graph.md +8 -0
- package/plugins/team-superpower/agents/implementer.md +0 -46
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
template: 14-research-feature-explainer
|
|
3
|
+
upstream: 14-research-feature-explainer.html
|
|
4
|
+
slots:
|
|
5
|
+
title: string # replaces first <h1> innerHTML
|
|
6
|
+
-->
|
|
7
|
+
<div class="page">
|
|
8
|
+
|
|
9
|
+
<!-- ββ side nav ββ -->
|
|
10
|
+
<nav>
|
|
11
|
+
<div class="label">On this page</div>
|
|
12
|
+
<a href="#tldr">TL;DR</a>
|
|
13
|
+
<a href="#path">Request path</a>
|
|
14
|
+
<a href="#path" class="l2">1. Identify</a>
|
|
15
|
+
<a href="#path" class="l2">2. Bucket lookup</a>
|
|
16
|
+
<a href="#path" class="l2">3. Consume</a>
|
|
17
|
+
<a href="#path" class="l2">4. Reject</a>
|
|
18
|
+
<a href="#config">Configuring a route</a>
|
|
19
|
+
<a href="#gotchas">Gotchas</a>
|
|
20
|
+
<a href="#faq">FAQ</a>
|
|
21
|
+
<div class="files">
|
|
22
|
+
<div class="label">Files read</div>
|
|
23
|
+
<code>middleware/ratelimit.ts</code>
|
|
24
|
+
<code>lib/tokenBucket.ts</code>
|
|
25
|
+
<code>config/limits.yaml</code>
|
|
26
|
+
<code>routes/*.ts</code>
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
|
|
30
|
+
<!-- ββ main ββ -->
|
|
31
|
+
<main>
|
|
32
|
+
<header>
|
|
33
|
+
<div class="eyebrow">Research & Learning Β· feature summary</div>
|
|
34
|
+
<h1>{{title}}</h1>
|
|
35
|
+
<div class="tldr" id="tldr">
|
|
36
|
+
<b>TL;DR</b> β Every request passes through <code>rateLimit()</code> middleware, which resolves the
|
|
37
|
+
caller to a <em>bucket key</em>, fetches a token-bucket from Redis, and either consumes one token or
|
|
38
|
+
returns <code>429</code>. Limits are declared per-route in <code>config/limits.yaml</code>; routes
|
|
39
|
+
without an entry inherit the <code>default</code> tier (100 req/min per API key).
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
|
|
43
|
+
<h2 id="path">The request path, step by step</h2>
|
|
44
|
+
<p>Expand each step to see what runs and where it lives. The whole path is ~40 lines and adds about
|
|
45
|
+
0.4 ms p50 to every request.</p>
|
|
46
|
+
|
|
47
|
+
<details open>
|
|
48
|
+
<summary>1 Β· Identify the caller <span class="where">middleware/ratelimit.ts:21</span></summary>
|
|
49
|
+
<div class="body">
|
|
50
|
+
<p>The middleware first reduces the request to a <code>bucketKey</code>: API key if an
|
|
51
|
+
<code>Authorization</code> header is present, otherwise the client IP (via the
|
|
52
|
+
<code>x-forwarded-for</code> chain, trusting only our own LB). Anonymous IP traffic gets a much
|
|
53
|
+
lower default tier.</p>
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<details>
|
|
58
|
+
<summary>2 Β· Look up the bucket <span class="where">lib/tokenBucket.ts:9</span></summary>
|
|
59
|
+
<div class="body">
|
|
60
|
+
<p>The route name plus bucket key map to a Redis hash (<code>rl:{route}:{key}</code>) holding
|
|
61
|
+
<code>tokens</code> and <code>updatedAt</code>. If the key is missing it's created lazily at full
|
|
62
|
+
capacity β there's no warm-up.</p>
|
|
63
|
+
</div>
|
|
64
|
+
</details>
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary>3 Β· Refill and consume <span class="where">lib/tokenBucket.ts:31</span></summary>
|
|
68
|
+
<div class="body">
|
|
69
|
+
<p>Refill is computed from elapsed time (<code>rate Γ Ξt</code>, capped at <code>burst</code>), then
|
|
70
|
+
one token is subtracted. The whole read-modify-write runs as a single Lua script so concurrent
|
|
71
|
+
requests can't double-spend.</p>
|
|
72
|
+
</div>
|
|
73
|
+
</details>
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary>4 Β· Reject when empty <span class="where">middleware/ratelimit.ts:48</span></summary>
|
|
77
|
+
<div class="body">
|
|
78
|
+
<p>If the script returns <code>tokens < 0</code> the middleware short-circuits with
|
|
79
|
+
<code>429 Too Many Requests</code> and sets <code>Retry-After</code> to the seconds until one token
|
|
80
|
+
refills. Successful responses always carry <code>X-RateLimit-Remaining</code>.</p>
|
|
81
|
+
</div>
|
|
82
|
+
</details>
|
|
83
|
+
|
|
84
|
+
<h2 id="config">Configuring a limit on your route</h2>
|
|
85
|
+
<p>You don't touch the middleware. Add an entry to <code>config/limits.yaml</code> keyed by route name,
|
|
86
|
+
and (optionally) tag the route so the middleware can find it.</p>
|
|
87
|
+
|
|
88
|
+
<div class="tabs" data-tabs>
|
|
89
|
+
<div class="tabbar">
|
|
90
|
+
<button class="on" data-t="0">limits.yaml</button>
|
|
91
|
+
<button data-t="1">route.ts</button>
|
|
92
|
+
<button data-t="2">client response</button>
|
|
93
|
+
</div>
|
|
94
|
+
<pre class="on"><span class="cm"># config/limits.yaml</span>
|
|
95
|
+
default:
|
|
96
|
+
rate: 100/min
|
|
97
|
+
burst: 120
|
|
98
|
+
|
|
99
|
+
<span class="hl">search.query</span>:
|
|
100
|
+
rate: 20/min
|
|
101
|
+
burst: 40
|
|
102
|
+
key: api_key <span class="cm"># or: ip</span></pre>
|
|
103
|
+
<pre><span class="cm">// routes/search.ts</span>
|
|
104
|
+
router.post(
|
|
105
|
+
"/search",
|
|
106
|
+
<span class="hl">rateLimit("search.query")</span>,
|
|
107
|
+
handler,
|
|
108
|
+
);</pre>
|
|
109
|
+
<pre>HTTP/1.1 429 Too Many Requests
|
|
110
|
+
Retry-After: 17
|
|
111
|
+
X-RateLimit-Limit: 20
|
|
112
|
+
X-RateLimit-Remaining: 0
|
|
113
|
+
|
|
114
|
+
{ "error": "rate_limited", "retry_after": 17 }</pre>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="callout">
|
|
118
|
+
<span class="ico">β
</span>
|
|
119
|
+
<div>If you only need the default tier, you don't need a YAML entry at all β just wrap the handler in
|
|
120
|
+
<code>rateLimit()</code> with no argument. The route name is inferred from the path.</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<h2 id="gotchas">Gotchas worth knowing</h2>
|
|
124
|
+
<ul style="padding-left:20px; max-width:680px;">
|
|
125
|
+
<li style="margin-bottom:8px;"><b>Limits are per-process in dev.</b> The Redis client falls back to an
|
|
126
|
+
in-memory map when <code>REDIS_URL</code> is unset, so local testing won't reflect real cluster
|
|
127
|
+
behaviour.</li>
|
|
128
|
+
<li style="margin-bottom:8px;"><b>Burst β rate.</b> <code>burst</code> is the bucket capacity; a caller
|
|
129
|
+
idle for a minute can fire <code>burst</code> requests instantly even if <code>rate</code> is low.</li>
|
|
130
|
+
<li><b>Streaming responses count once.</b> The token is consumed at request start; a 30-second SSE
|
|
131
|
+
stream still costs one token.</li>
|
|
132
|
+
</ul>
|
|
133
|
+
|
|
134
|
+
<h2 id="faq">FAQ</h2>
|
|
135
|
+
<dl class="faq">
|
|
136
|
+
<dt>How do I exempt internal traffic?</dt>
|
|
137
|
+
<dd>Set <code>x-acme-internal: 1</code> from the caller; the middleware checks it against the
|
|
138
|
+
mTLS peer name and skips the bucket entirely.</dd>
|
|
139
|
+
|
|
140
|
+
<dt>Where do I see who's getting limited?</dt>
|
|
141
|
+
<dd>Every <code>429</code> emits a <code>ratelimit.rejected</code> metric tagged with route and key
|
|
142
|
+
type. There's a Grafana panel under <em>API β Health</em>.</dd>
|
|
143
|
+
|
|
144
|
+
<dt>Can a single user have a higher limit?</dt>
|
|
145
|
+
<dd>Yes β add their API key under <code>overrides:</code> in the YAML. Overrides are reloaded without
|
|
146
|
+
a deploy.</dd>
|
|
147
|
+
</dl>
|
|
148
|
+
</main>
|
|
149
|
+
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
154
|
+
.page {
|
|
155
|
+
max-width: 1100px;
|
|
156
|
+
margin: 0 auto;
|
|
157
|
+
display: grid;
|
|
158
|
+
grid-template-columns: 200px minmax(0, 1fr);
|
|
159
|
+
gap: 48px;
|
|
160
|
+
}
|
|
161
|
+
@media (max-width: 920px) { .page { grid-template-columns: 1fr; } nav { display: none; } }
|
|
162
|
+
/* ββ nav ββββββββββββββββββββββββββββββββ */
|
|
163
|
+
nav {
|
|
164
|
+
position: sticky;
|
|
165
|
+
top: 32px;
|
|
166
|
+
align-self: start;
|
|
167
|
+
font-size: 13px;
|
|
168
|
+
}
|
|
169
|
+
nav .label {
|
|
170
|
+
font-family: var(--mono);
|
|
171
|
+
font-size: 10px;
|
|
172
|
+
letter-spacing: 0.1em;
|
|
173
|
+
text-transform: uppercase;
|
|
174
|
+
color: var(--gray-500);
|
|
175
|
+
margin-bottom: 12px;
|
|
176
|
+
}
|
|
177
|
+
nav a {
|
|
178
|
+
display: block;
|
|
179
|
+
padding: 5px 0 5px 12px;
|
|
180
|
+
border-left: 2px solid var(--gray-300);
|
|
181
|
+
color: var(--gray-700);
|
|
182
|
+
text-decoration: none;
|
|
183
|
+
}
|
|
184
|
+
nav a:hover { color: var(--slate); border-color: var(--slate); }
|
|
185
|
+
nav a.l2 { padding-left: 24px; font-size: 12.5px; color: var(--gray-500); }
|
|
186
|
+
nav .files {
|
|
187
|
+
margin-top: 28px;
|
|
188
|
+
border-top: 1px solid var(--gray-300);
|
|
189
|
+
padding-top: 16px;
|
|
190
|
+
}
|
|
191
|
+
nav .files code {
|
|
192
|
+
display: block;
|
|
193
|
+
font-family: var(--mono);
|
|
194
|
+
font-size: 11px;
|
|
195
|
+
color: var(--gray-500);
|
|
196
|
+
padding: 3px 0;
|
|
197
|
+
}
|
|
198
|
+
/* ββ header βββββββββββββββββββββββββββββ */
|
|
199
|
+
header { margin-bottom: 12px; }
|
|
200
|
+
.eyebrow {
|
|
201
|
+
font-family: var(--mono);
|
|
202
|
+
font-size: 11px;
|
|
203
|
+
letter-spacing: 0.08em;
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
color: var(--gray-500);
|
|
206
|
+
margin-bottom: 10px;
|
|
207
|
+
}
|
|
208
|
+
h1 {
|
|
209
|
+
font-family: var(--serif);
|
|
210
|
+
font-weight: 500;
|
|
211
|
+
font-size: 32px;
|
|
212
|
+
color: var(--slate);
|
|
213
|
+
letter-spacing: -0.01em;
|
|
214
|
+
margin-bottom: 14px;
|
|
215
|
+
}
|
|
216
|
+
.tldr {
|
|
217
|
+
border: 1.5px solid var(--gray-300);
|
|
218
|
+
border-left: 3px solid var(--clay);
|
|
219
|
+
border-radius: 10px;
|
|
220
|
+
background: #fff;
|
|
221
|
+
padding: 16px 18px;
|
|
222
|
+
margin-bottom: 8px;
|
|
223
|
+
}
|
|
224
|
+
.tldr b { color: var(--slate); }
|
|
225
|
+
h2 {
|
|
226
|
+
font-family: var(--serif);
|
|
227
|
+
font-weight: 500;
|
|
228
|
+
font-size: 22px;
|
|
229
|
+
color: var(--slate);
|
|
230
|
+
margin: 40px 0 14px;
|
|
231
|
+
scroll-margin-top: 24px;
|
|
232
|
+
}
|
|
233
|
+
p { margin-bottom: 12px; max-width: 680px; }
|
|
234
|
+
code { font-family: var(--mono); font-size: 13px; }
|
|
235
|
+
/* ββ collapsible βββββββββββββββββββββββ */
|
|
236
|
+
details {
|
|
237
|
+
border: 1.5px solid var(--gray-300);
|
|
238
|
+
border-radius: 10px;
|
|
239
|
+
background: #fff;
|
|
240
|
+
margin: 14px 0;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
}
|
|
243
|
+
summary {
|
|
244
|
+
list-style: none;
|
|
245
|
+
cursor: pointer;
|
|
246
|
+
padding: 14px 16px;
|
|
247
|
+
font-family: var(--serif);
|
|
248
|
+
font-size: 16px;
|
|
249
|
+
color: var(--slate);
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: baseline;
|
|
252
|
+
gap: 10px;
|
|
253
|
+
}
|
|
254
|
+
summary::-webkit-details-marker { display: none; }
|
|
255
|
+
summary::before {
|
|
256
|
+
content: "βΈ";
|
|
257
|
+
color: var(--clay);
|
|
258
|
+
font-family: var(--sans);
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
transition: transform 120ms;
|
|
261
|
+
}
|
|
262
|
+
details[open] summary::before { transform: rotate(90deg); }
|
|
263
|
+
summary .where {
|
|
264
|
+
font-family: var(--mono);
|
|
265
|
+
font-size: 11px;
|
|
266
|
+
color: var(--gray-500);
|
|
267
|
+
margin-left: auto;
|
|
268
|
+
}
|
|
269
|
+
details .body { padding: 0 16px 16px; }
|
|
270
|
+
details .body p { font-size: 14px; }
|
|
271
|
+
/* ββ tabs βββββββββββββββββββββββββββββββ */
|
|
272
|
+
.tabs {
|
|
273
|
+
border: 1.5px solid var(--gray-300);
|
|
274
|
+
border-radius: 10px;
|
|
275
|
+
background: #fff;
|
|
276
|
+
margin: 16px 0 8px;
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
}
|
|
279
|
+
.tabbar {
|
|
280
|
+
display: flex;
|
|
281
|
+
border-bottom: 1px solid var(--gray-300);
|
|
282
|
+
background: var(--gray-150);
|
|
283
|
+
}
|
|
284
|
+
.tabbar button {
|
|
285
|
+
appearance: none;
|
|
286
|
+
border: none;
|
|
287
|
+
background: none;
|
|
288
|
+
font-family: var(--mono);
|
|
289
|
+
font-size: 12px;
|
|
290
|
+
color: var(--gray-500);
|
|
291
|
+
padding: 10px 16px;
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
border-right: 1px solid var(--gray-300);
|
|
294
|
+
}
|
|
295
|
+
.tabbar button.on {
|
|
296
|
+
background: #fff;
|
|
297
|
+
color: var(--slate);
|
|
298
|
+
border-bottom: 2px solid var(--clay);
|
|
299
|
+
margin-bottom: -1px;
|
|
300
|
+
}
|
|
301
|
+
.tabs pre {
|
|
302
|
+
display: none;
|
|
303
|
+
margin: 0;
|
|
304
|
+
padding: 16px 18px;
|
|
305
|
+
font-family: var(--mono);
|
|
306
|
+
font-size: 12.5px;
|
|
307
|
+
line-height: 1.6;
|
|
308
|
+
color: var(--slate);
|
|
309
|
+
overflow-x: auto;
|
|
310
|
+
}
|
|
311
|
+
.tabs pre.on { display: block; }
|
|
312
|
+
.hl { color: var(--clay); }
|
|
313
|
+
.cm { color: var(--gray-500); }
|
|
314
|
+
/* ββ callout ββββββββββββββββββββββββββββ */
|
|
315
|
+
.callout {
|
|
316
|
+
display: flex;
|
|
317
|
+
gap: 12px;
|
|
318
|
+
border: 1.5px solid var(--oat);
|
|
319
|
+
background: rgba(227,218,204,0.35);
|
|
320
|
+
border-radius: 10px;
|
|
321
|
+
padding: 14px 16px;
|
|
322
|
+
margin: 18px 0;
|
|
323
|
+
font-size: 14px;
|
|
324
|
+
}
|
|
325
|
+
.callout .ico { color: var(--clay); font-weight: 600; }
|
|
326
|
+
/* ββ faq ββββββββββββββββββββββββββββββββ */
|
|
327
|
+
dl.faq { margin-top: 8px; }
|
|
328
|
+
dl.faq dt {
|
|
329
|
+
font-family: var(--serif);
|
|
330
|
+
font-size: 16px;
|
|
331
|
+
color: var(--slate);
|
|
332
|
+
margin-top: 18px;
|
|
333
|
+
}
|
|
334
|
+
dl.faq dd { font-size: 14px; margin: 4px 0 0; max-width: 640px; }
|
|
335
|
+
</style>
|
|
336
|
+
|
|
337
|
+
<script>
|
|
338
|
+
document.querySelectorAll("[data-tabs]").forEach(box => {
|
|
339
|
+
const btns = box.querySelectorAll("button");
|
|
340
|
+
const panes = box.querySelectorAll("pre");
|
|
341
|
+
btns.forEach(b => b.addEventListener("click", () => {
|
|
342
|
+
btns.forEach(x => x.classList.remove("on"));
|
|
343
|
+
panes.forEach(x => x.classList.remove("on"));
|
|
344
|
+
b.classList.add("on");
|
|
345
|
+
panes[+b.dataset.t].classList.add("on");
|
|
346
|
+
}));
|
|
347
|
+
});
|
|
348
|
+
</script>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
template: 15-research-concept-explainer
|
|
3
|
+
upstream: 15-research-concept-explainer.html
|
|
4
|
+
slots:
|
|
5
|
+
title: string # replaces first <h1> innerHTML
|
|
6
|
+
-->
|
|
7
|
+
<div class="page">
|
|
8
|
+
|
|
9
|
+
<main>
|
|
10
|
+
<div class="eyebrow">Research & Learning Β· concept explainer</div>
|
|
11
|
+
<h1>{{title}}</h1>
|
|
12
|
+
<p class="lead">
|
|
13
|
+
You have <em>K</em> keys spread across <em>N</em> cache servers. A server dies, or you add one. How many
|
|
14
|
+
keys have to move? With naive <code>hash(key) mod N</code> the answer is "almost all of them." Consistent
|
|
15
|
+
hashing gets it down to roughly <code>K / N</code>. Here's why.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<h2>The trick: hash onto a circle, not a line</h2>
|
|
19
|
+
<p>
|
|
20
|
+
Map both <span class="term" data-term="node">nodes</span> and keys onto the same
|
|
21
|
+
<span class="term" data-term="ring">ring</span> (the hash output space, wrapped around). A key belongs to
|
|
22
|
+
the first node found by walking clockwise from the key's position. When a node leaves, only the keys in
|
|
23
|
+
its <span class="term" data-term="arc">arc</span> reassign β to the next node round β and everything else
|
|
24
|
+
stays put.
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<div class="demo">
|
|
28
|
+
<div class="demo-grid">
|
|
29
|
+
<svg class="ring" id="ring" viewBox="0 0 260 260"></svg>
|
|
30
|
+
<div class="controls">
|
|
31
|
+
<div class="row">
|
|
32
|
+
<label>nodes</label>
|
|
33
|
+
<input id="nSlider" type="range" min="2" max="8" value="4">
|
|
34
|
+
<span class="val" id="nVal">4</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="row">
|
|
37
|
+
<label>keys</label>
|
|
38
|
+
<input id="kSlider" type="range" min="10" max="60" value="32" step="2">
|
|
39
|
+
<span class="val" id="kVal">32</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<button id="rm">remove a node</button>
|
|
43
|
+
<button id="add">add a node</button>
|
|
44
|
+
<button id="reset">reset</button>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="readout" id="readout">
|
|
47
|
+
<b>4</b> nodes Β· <b>32</b> keys Β· <span class="moved">β</span> moved on last change
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<p style="font-size:13px; color:var(--gray-500); max-width:640px;">
|
|
53
|
+
Colored arcs show ownership. Removing a node hands its arc to its clockwise neighbor; every dot outside
|
|
54
|
+
that arc keeps its color. That's the whole idea.
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<h2>Versus <code>mod N</code></h2>
|
|
58
|
+
<table>
|
|
59
|
+
<thead><tr><th></th><th>hash mod N</th><th>consistent hashing</th></tr></thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
<tr><td>Keys moved when NβN+1</td><td class="bad">~ (Nβ1)/N of all keys</td><td class="good">~ 1/(N+1)</td></tr>
|
|
62
|
+
<tr><td>Hot-spot risk</td><td class="good">even by construction</td><td>uneven β fix with <span class="term" data-term="vnode">virtual nodes</span></td></tr>
|
|
63
|
+
<tr><td>Lookup cost</td><td class="good">O(1)</td><td>O(log N) (binary search on ring)</td></tr>
|
|
64
|
+
<tr><td>Used by</td><td>array sharding, simple LB</td><td>Dynamo, Cassandra, Memcached clients, Envoy</td></tr>
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
|
|
68
|
+
<h2>Where you'll meet it</h2>
|
|
69
|
+
<p>
|
|
70
|
+
Any time you're spreading state across a pool that changes size: cache fleets, partitioned queues, object
|
|
71
|
+
storage, request routing with sticky sessions. The
|
|
72
|
+
<span class="term" data-term="vnode">virtual-node</span> variant (each physical node owns many small arcs
|
|
73
|
+
instead of one big one) is what production systems actually run, because it smooths out load and makes
|
|
74
|
+
rebalancing even gentler.
|
|
75
|
+
</p>
|
|
76
|
+
</main>
|
|
77
|
+
|
|
78
|
+
<!-- glossary -->
|
|
79
|
+
<aside>
|
|
80
|
+
<div class="label">Glossary</div>
|
|
81
|
+
<dl id="gloss">
|
|
82
|
+
<dt data-g="ring">Ring</dt>
|
|
83
|
+
<dd>The hash function's output range, treated as a circle so the value after <code>max</code> is <code>0</code>.</dd>
|
|
84
|
+
<dt data-g="node">Node</dt>
|
|
85
|
+
<dd>A server placed on the ring at <code>hash(node_id)</code>. Owns every key between it and its anticlockwise neighbor.</dd>
|
|
86
|
+
<dt data-g="arc">Arc</dt>
|
|
87
|
+
<dd>The stretch of ring a node owns. Removing a node merges its arc into the next node's.</dd>
|
|
88
|
+
<dt data-g="vnode">Virtual node</dt>
|
|
89
|
+
<dd>Placing each physical node at many ring positions so arcs are small and evenly sized.</dd>
|
|
90
|
+
<dt data-g="successor">Successor</dt>
|
|
91
|
+
<dd>The first node clockwise from a given point β the owner of any key landing there.</dd>
|
|
92
|
+
</dl>
|
|
93
|
+
</aside>
|
|
94
|
+
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
99
|
+
.page {
|
|
100
|
+
max-width: 1100px;
|
|
101
|
+
margin: 0 auto;
|
|
102
|
+
display: grid;
|
|
103
|
+
grid-template-columns: minmax(0, 1fr) 240px;
|
|
104
|
+
gap: 48px;
|
|
105
|
+
}
|
|
106
|
+
@media (max-width: 960px) { .page { grid-template-columns: 1fr; } aside { order: 2; position: static; } }
|
|
107
|
+
/* ββ header βββββββββββββββββββββββββββββ */
|
|
108
|
+
.eyebrow {
|
|
109
|
+
font-family: var(--mono);
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
letter-spacing: 0.08em;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
color: var(--gray-500);
|
|
114
|
+
margin-bottom: 10px;
|
|
115
|
+
}
|
|
116
|
+
h1 {
|
|
117
|
+
font-family: var(--serif);
|
|
118
|
+
font-weight: 500;
|
|
119
|
+
font-size: 33px;
|
|
120
|
+
color: var(--slate);
|
|
121
|
+
letter-spacing: -0.01em;
|
|
122
|
+
margin-bottom: 12px;
|
|
123
|
+
}
|
|
124
|
+
.lead { max-width: 640px; margin-bottom: 8px; }
|
|
125
|
+
h2 {
|
|
126
|
+
font-family: var(--serif);
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
font-size: 22px;
|
|
129
|
+
color: var(--slate);
|
|
130
|
+
margin: 40px 0 12px;
|
|
131
|
+
}
|
|
132
|
+
p { margin-bottom: 12px; max-width: 680px; }
|
|
133
|
+
code { font-family: var(--mono); font-size: 13px; }
|
|
134
|
+
.term {
|
|
135
|
+
border-bottom: 1.5px dotted var(--clay);
|
|
136
|
+
cursor: help;
|
|
137
|
+
color: var(--slate);
|
|
138
|
+
}
|
|
139
|
+
/* ββ demo panel βββββββββββββββββββββββββ */
|
|
140
|
+
.demo {
|
|
141
|
+
border: 1.5px solid var(--gray-300);
|
|
142
|
+
border-radius: 14px;
|
|
143
|
+
background: #fff;
|
|
144
|
+
padding: 24px;
|
|
145
|
+
margin: 12px 0 8px;
|
|
146
|
+
}
|
|
147
|
+
.demo-grid {
|
|
148
|
+
display: grid;
|
|
149
|
+
grid-template-columns: 320px 1fr;
|
|
150
|
+
gap: 28px;
|
|
151
|
+
align-items: center;
|
|
152
|
+
}
|
|
153
|
+
@media (max-width: 760px) { .demo-grid { grid-template-columns: 1fr; } }
|
|
154
|
+
svg.ring { display: block; width: 100%; max-width: 320px; }
|
|
155
|
+
.ring .track { fill: none; stroke: var(--gray-300); stroke-width: 14; }
|
|
156
|
+
.ring .arc { fill: none; stroke-width: 14; transition: stroke-dasharray 300ms ease; }
|
|
157
|
+
.ring .node { stroke: #fff; stroke-width: 2; transition: cx 300ms, cy 300ms, opacity 200ms; }
|
|
158
|
+
.ring .key { fill: var(--slate); transition: cx 300ms, cy 300ms; }
|
|
159
|
+
.ring .lbl { font-family: var(--mono); font-size: 10px; fill: var(--gray-500); }
|
|
160
|
+
.controls { font-size: 13px; }
|
|
161
|
+
.controls .row { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
|
162
|
+
.controls label { width: 64px; color: var(--gray-500); font-family: var(--mono); font-size: 11px; }
|
|
163
|
+
.controls .val { font-family: var(--mono); font-size: 12px; color: var(--slate); width: 28px; }
|
|
164
|
+
.controls input[type=range] { flex: 1; accent-color: var(--clay); }
|
|
165
|
+
.controls button {
|
|
166
|
+
appearance: none;
|
|
167
|
+
border: 1.5px solid var(--gray-300);
|
|
168
|
+
background: var(--gray-150);
|
|
169
|
+
border-radius: 6px;
|
|
170
|
+
font-family: var(--mono);
|
|
171
|
+
font-size: 11px;
|
|
172
|
+
padding: 6px 10px;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
margin-right: 6px;
|
|
175
|
+
}
|
|
176
|
+
.controls button:hover { background: var(--oat); }
|
|
177
|
+
.readout {
|
|
178
|
+
border-top: 1px solid var(--gray-300);
|
|
179
|
+
margin-top: 16px;
|
|
180
|
+
padding-top: 14px;
|
|
181
|
+
font-size: 13px;
|
|
182
|
+
}
|
|
183
|
+
.readout b { color: var(--slate); }
|
|
184
|
+
.moved { color: var(--clay); font-family: var(--mono); }
|
|
185
|
+
/* ββ compare table ββββββββββββββββββββββ */
|
|
186
|
+
table {
|
|
187
|
+
border-collapse: collapse;
|
|
188
|
+
width: 100%;
|
|
189
|
+
max-width: 640px;
|
|
190
|
+
font-size: 13.5px;
|
|
191
|
+
margin: 12px 0;
|
|
192
|
+
}
|
|
193
|
+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--gray-300); }
|
|
194
|
+
th { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--gray-500); font-weight: 500; }
|
|
195
|
+
td.bad { color: #B04A3F; }
|
|
196
|
+
td.good { color: var(--olive); }
|
|
197
|
+
/* ββ glossary βββββββββββββββββββββββββββ */
|
|
198
|
+
aside {
|
|
199
|
+
position: sticky;
|
|
200
|
+
top: 32px;
|
|
201
|
+
align-self: start;
|
|
202
|
+
border: 1.5px solid var(--gray-300);
|
|
203
|
+
border-radius: 12px;
|
|
204
|
+
background: #fff;
|
|
205
|
+
padding: 18px 18px 8px;
|
|
206
|
+
}
|
|
207
|
+
aside .label {
|
|
208
|
+
font-family: var(--mono);
|
|
209
|
+
font-size: 10px;
|
|
210
|
+
letter-spacing: 0.1em;
|
|
211
|
+
text-transform: uppercase;
|
|
212
|
+
color: var(--gray-500);
|
|
213
|
+
margin-bottom: 12px;
|
|
214
|
+
}
|
|
215
|
+
aside dl dt {
|
|
216
|
+
font-family: var(--serif);
|
|
217
|
+
font-size: 15px;
|
|
218
|
+
color: var(--slate);
|
|
219
|
+
margin-top: 0;
|
|
220
|
+
}
|
|
221
|
+
aside dl dd {
|
|
222
|
+
font-size: 12.5px;
|
|
223
|
+
line-height: 1.5;
|
|
224
|
+
color: var(--gray-700);
|
|
225
|
+
margin: 2px 0 14px;
|
|
226
|
+
}
|
|
227
|
+
aside dl dt.hl, aside dl dt.hl + dd { background: rgba(217,119,87,0.10); margin-left: -8px; margin-right: -8px; padding-left: 8px; padding-right: 8px; border-radius: 4px; }
|
|
228
|
+
</style>
|
|
229
|
+
|
|
230
|
+
<script>
|
|
231
|
+
/* ββ tiny deterministic hash so the demo is stable ββ */
|
|
232
|
+
function h(s) {
|
|
233
|
+
let x = 2166136261;
|
|
234
|
+
for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = (x * 16777619) >>> 0; }
|
|
235
|
+
return x / 4294967296;
|
|
236
|
+
}
|
|
237
|
+
const COLORS = ["#D97757", "#788C5D", "#6A8CAF", "#C2A83E", "#B04A3F", "#87867F", "#3D6E6E", "#A67C52"];
|
|
238
|
+
const CX = 130, CY = 130, R = 100, RKEY = 78;
|
|
239
|
+
const TAU = Math.PI * 2;
|
|
240
|
+
|
|
241
|
+
const ring = document.getElementById("ring");
|
|
242
|
+
const nSlider = document.getElementById("nSlider");
|
|
243
|
+
const kSlider = document.getElementById("kSlider");
|
|
244
|
+
const nVal = document.getElementById("nVal");
|
|
245
|
+
const kVal = document.getElementById("kVal");
|
|
246
|
+
const readout = document.getElementById("readout");
|
|
247
|
+
|
|
248
|
+
let nodes = [], keys = [], lastOwner = {};
|
|
249
|
+
|
|
250
|
+
function pt(r, t) { return [CX + r * Math.sin(t * TAU), CY - r * Math.cos(t * TAU)]; }
|
|
251
|
+
|
|
252
|
+
function ownerOf(t) {
|
|
253
|
+
const sorted = [...nodes].sort((a, b) => a.t - b.t);
|
|
254
|
+
for (const n of sorted) if (n.t >= t) return n;
|
|
255
|
+
return sorted[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildNodes(n) {
|
|
259
|
+
nodes = [];
|
|
260
|
+
for (let i = 0; i < n; i++) nodes.push({ id: "n" + i, t: h("node-" + i), color: COLORS[i % COLORS.length] });
|
|
261
|
+
}
|
|
262
|
+
function buildKeys(k) {
|
|
263
|
+
keys = [];
|
|
264
|
+
for (let i = 0; i < k; i++) keys.push({ id: "k" + i, t: h("key-" + i) });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function arcPath(t0, t1) {
|
|
268
|
+
const [x0, y0] = pt(R, t0), [x1, y1] = pt(R, t1);
|
|
269
|
+
let d = t1 - t0; if (d <= 0) d += 1;
|
|
270
|
+
const large = d > 0.5 ? 1 : 0;
|
|
271
|
+
return `M ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function render(moved) {
|
|
275
|
+
const sorted = [...nodes].sort((a, b) => a.t - b.t);
|
|
276
|
+
let svg = `<circle class="track" cx="${CX}" cy="${CY}" r="${R}"/>`;
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
279
|
+
const cur = sorted[i];
|
|
280
|
+
const prev = sorted[(i - 1 + sorted.length) % sorted.length];
|
|
281
|
+
svg += `<path class="arc" stroke="${cur.color}" d="${arcPath(prev.t, cur.t)}"/>`;
|
|
282
|
+
}
|
|
283
|
+
for (const k of keys) {
|
|
284
|
+
const o = ownerOf(k.t);
|
|
285
|
+
const [x, y] = pt(RKEY, k.t);
|
|
286
|
+
svg += `<circle class="key" r="3.5" cx="${x}" cy="${y}" fill="${o.color}"/>`;
|
|
287
|
+
}
|
|
288
|
+
for (const n of sorted) {
|
|
289
|
+
const [x, y] = pt(R, n.t);
|
|
290
|
+
svg += `<circle class="node" r="9" cx="${x}" cy="${y}" fill="${n.color}"/>`;
|
|
291
|
+
}
|
|
292
|
+
svg += `<text class="lbl" x="${CX}" y="18" text-anchor="middle">0</text>`;
|
|
293
|
+
ring.innerHTML = svg;
|
|
294
|
+
|
|
295
|
+
readout.innerHTML =
|
|
296
|
+
`<b>${nodes.length}</b> nodes Β· <b>${keys.length}</b> keys Β· ` +
|
|
297
|
+
`<span class="moved">${moved == null ? "β" : moved + " (" + Math.round(moved / keys.length * 100) + "%)"}</span> moved on last change`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function diffAndRender() {
|
|
301
|
+
let moved = 0;
|
|
302
|
+
for (const k of keys) {
|
|
303
|
+
const o = ownerOf(k.t).id;
|
|
304
|
+
if (lastOwner[k.id] && lastOwner[k.id] !== o) moved++;
|
|
305
|
+
lastOwner[k.id] = o;
|
|
306
|
+
}
|
|
307
|
+
render(moved);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function reset() {
|
|
311
|
+
buildNodes(+nSlider.value);
|
|
312
|
+
buildKeys(+kSlider.value);
|
|
313
|
+
lastOwner = {};
|
|
314
|
+
for (const k of keys) lastOwner[k.id] = ownerOf(k.t).id;
|
|
315
|
+
nVal.textContent = nSlider.value;
|
|
316
|
+
kVal.textContent = kSlider.value;
|
|
317
|
+
render(null);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
nSlider.oninput = () => { nVal.textContent = nSlider.value; buildNodes(+nSlider.value); diffAndRender(); };
|
|
321
|
+
kSlider.oninput = () => { kVal.textContent = kSlider.value; buildKeys(+kSlider.value); lastOwner = {}; for (const k of keys) lastOwner[k.id] = ownerOf(k.t).id; render(null); };
|
|
322
|
+
document.getElementById("rm").onclick = () => { if (nodes.length > 2) { nodes.splice(Math.floor(Math.random() * nodes.length), 1); diffAndRender(); } };
|
|
323
|
+
document.getElementById("add").onclick = () => { if (nodes.length < 12) { const i = nodes.length; nodes.push({ id: "n" + Date.now(), t: Math.random(), color: COLORS[i % COLORS.length] }); diffAndRender(); } };
|
|
324
|
+
document.getElementById("reset").onclick = reset;
|
|
325
|
+
|
|
326
|
+
/* glossary highlight */
|
|
327
|
+
document.querySelectorAll(".term").forEach(el => {
|
|
328
|
+
const g = el.dataset.term;
|
|
329
|
+
el.addEventListener("mouseenter", () => document.querySelector(`dt[data-g="${g}"]`)?.classList.add("hl"));
|
|
330
|
+
el.addEventListener("mouseleave", () => document.querySelector(`dt[data-g="${g}"]`)?.classList.remove("hl"));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
reset();
|
|
334
|
+
</script>
|