@plot-pm/board 0.15.0 → 0.16.1
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-server.mjs +109 -107
- package/package.json +2 -1
- package/plot-agent-manifest.sh +80 -1
- package/plot-approve.sh +3 -1
- package/plot-budget.sh +378 -19
- package/plot-config.sh +61 -9
- package/plot-deliver.sh +3 -1
- package/plot-dispatch.sh +149 -46
- package/plot-fleet-scan.sh +61 -150
- package/plot-host.sh +227 -72
- package/plot-plan-meta.sh +58 -10
- package/plot-reap.sh +104 -8
- package/plot-resolve-artifact.sh +4 -2
- package/plot-tmp.sh +108 -0
- package/plot-transcript-quiet.sh +29 -1
- package/plot-worker-monitor.sh +66 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"plot-release-refs.sh",
|
|
41
41
|
"plot-resolve-artifact.sh",
|
|
42
42
|
"plot-state-receipt.sh",
|
|
43
|
+
"plot-tmp.sh",
|
|
43
44
|
"plot-transcript-quiet.sh",
|
|
44
45
|
"plot-worker-monitor.sh",
|
|
45
46
|
"plot-worker-state.sh"
|
package/plot-agent-manifest.sh
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
# arrived. The loop is a script, not a library, so sourcing it would run it; the
|
|
14
14
|
# body moved here unchanged and the loop sources this file instead.
|
|
15
15
|
#
|
|
16
|
-
# Defines
|
|
16
|
+
# Defines four functions and does nothing else on load: `clear_manifest_branch`;
|
|
17
|
+
# `plot_session_id`, which `plot-dispatch.sh` calls to launch an agent and
|
|
18
|
+
# `plot-worker-loop.sh` calls when a hop moves the agent to a new branch; and
|
|
19
|
+
# `manifest_resume_id` with `session_handle`, the conversation handle that the
|
|
20
|
+
# loop passes to the prompt and `plot-worker-monitor.sh` probes for a transcript.
|
|
17
21
|
|
|
18
22
|
# Clear `branch` when a slice finishes, so the window before the next one is
|
|
19
23
|
# observable.
|
|
@@ -56,3 +60,78 @@ clear_manifest_branch() { # $1=manifest
|
|
|
56
60
|
|
|
57
61
|
mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
58
62
|
}
|
|
63
|
+
|
|
64
|
+
# A session id, in the shape the runtime uses for its transcript filename.
|
|
65
|
+
#
|
|
66
|
+
# `uuidgen` where it exists (macOS and most Linux), falling back to `/dev/urandom`
|
|
67
|
+
# — never to `$RANDOM` or a timestamp. Two workers launched in the same second by
|
|
68
|
+
# the same fan-out would collide on either, and a collision here silently merges
|
|
69
|
+
# two agents into one manifest.
|
|
70
|
+
#
|
|
71
|
+
# Lowercased because the runtime writes its transcript filename in lowercase and
|
|
72
|
+
# the board joins on exact string equality; `uuidgen` on macOS returns uppercase.
|
|
73
|
+
plot_session_id() {
|
|
74
|
+
local id=""
|
|
75
|
+
if command -v uuidgen >/dev/null 2>&1; then
|
|
76
|
+
id=$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')
|
|
77
|
+
fi
|
|
78
|
+
if [ -z "$id" ]; then
|
|
79
|
+
# 16 random bytes rendered as a v4-shaped id. The shape matters only for
|
|
80
|
+
# recognisability; nothing parses it.
|
|
81
|
+
id=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' \
|
|
82
|
+
| sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
|
|
83
|
+
fi
|
|
84
|
+
printf '%s' "$id"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# The resume handle the manifest carries, or nothing while it carries none.
|
|
88
|
+
#
|
|
89
|
+
# A SECOND FIELD, NOT AN ALIAS FOR `session`. A dispatch writes the same value
|
|
90
|
+
# into both, and they part at the first hop to a new branch: `session` names the
|
|
91
|
+
# agent and its manifest file for the agent's whole life, while `resumeId` names
|
|
92
|
+
# the current slice's conversation and is what the board joins the transcript
|
|
93
|
+
# on. Reading this field rather than `$PLOT_SESSION_ID` is what makes the
|
|
94
|
+
# hop's write (`update_manifest_on_hop` in `plot-worker-loop.sh`) mean anything:
|
|
95
|
+
# a reader asks for the handle, and gets the one the hop last wrote.
|
|
96
|
+
#
|
|
97
|
+
# A PARSE FAILURE AND AN ABSENT MANIFEST ARE ONE ANSWER, the shape
|
|
98
|
+
# `assigned_branch` in `plot-worker-loop.sh` already takes: no handle. A hand-started loop has no
|
|
99
|
+
# manifest, and a manifest nobody can read is not a handle.
|
|
100
|
+
manifest_resume_id() { # $1=manifest → prints the handle, or nothing
|
|
101
|
+
local manifest="$1"
|
|
102
|
+
[ -n "$manifest" ] && [ -f "$manifest" ] || return 1
|
|
103
|
+
local id
|
|
104
|
+
id=$(node -e '
|
|
105
|
+
const fs = require("fs");
|
|
106
|
+
try {
|
|
107
|
+
const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
108
|
+
process.stdout.write(typeof manifest.resumeId === "string" ? manifest.resumeId : "");
|
|
109
|
+
} catch { process.stdout.write(""); }
|
|
110
|
+
' "$manifest" 2>/dev/null) || return 1
|
|
111
|
+
[ -n "$id" ] || return 1
|
|
112
|
+
printf '%s' "$id"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# THE HANDLE THE PROMPT CARRIES — the manifest's `resumeId`, or the launch id.
|
|
116
|
+
#
|
|
117
|
+
# `resumeId` IS ASKED FIRST BECAUSE IT IS THE FIELD THE HOP WRITES. A dispatch
|
|
118
|
+
# writes the launch id into both `session` and `resumeId`, so on a first slice
|
|
119
|
+
# the two answers are the same string and this reads as a no-op. It stops being
|
|
120
|
+
# one the moment the handle diverges from the join key — which is what the two
|
|
121
|
+
# fields exist to allow, and what a later `--fork-session` would do. The loop
|
|
122
|
+
# and `plot-worker-monitor.sh` both call this, so the prompt and the idle
|
|
123
|
+
# verdict ask about one conversation.
|
|
124
|
+
#
|
|
125
|
+
# `$PLOT_SESSION_ID` IS THE FALLBACK, NOT THE SOURCE. A hand-started loop has no
|
|
126
|
+
# manifest and a pre-`resumeId` manifest carries no handle; both are the launch
|
|
127
|
+
# id, which is what the prompt passed before this function existed. An absent
|
|
128
|
+
# manifest is not an absent session.
|
|
129
|
+
session_handle() { # → the handle, or nothing
|
|
130
|
+
local id
|
|
131
|
+
if id=$(manifest_resume_id "${PLOT_MANIFEST_FILE:-}"); then
|
|
132
|
+
printf '%s' "$id"
|
|
133
|
+
return 0
|
|
134
|
+
fi
|
|
135
|
+
[ -n "${PLOT_SESSION_ID:-}" ] || return 1
|
|
136
|
+
printf '%s' "$PLOT_SESSION_ID"
|
|
137
|
+
}
|
package/plot-approve.sh
CHANGED
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
set -uo pipefail
|
|
99
99
|
|
|
100
100
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
101
|
+
. "$script_dir/plot-tmp.sh"
|
|
101
102
|
|
|
102
103
|
# The receipt this script leaves for plot-state-gate.sh, which refuses every
|
|
103
104
|
# other writer of a `State:` line. Sourced rather than run: the gate and the
|
|
@@ -233,7 +234,8 @@ fi
|
|
|
233
234
|
# (a rate limit included), 4 for a backend with no answer at all. Only the
|
|
234
235
|
# first is an absence. The other two stop here with the host's own words and
|
|
235
236
|
# name no repair to the branch, because nothing about the branch was read.
|
|
236
|
-
pr_err_file
|
|
237
|
+
pr_err_file=""
|
|
238
|
+
plot_tmpfile pr_err_file approve-pr
|
|
237
239
|
pr_rc=0
|
|
238
240
|
pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
|
|
239
241
|
pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
|
package/plot-budget.sh
CHANGED
|
@@ -8,11 +8,25 @@
|
|
|
8
8
|
# `plot-worker-state.sh`: the caller parses its own `$@`, so a file that ran an
|
|
9
9
|
# argument parser at load time could not be sourced.
|
|
10
10
|
#
|
|
11
|
-
# IT APPENDS
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# IT APPENDS, READS TWO GENERATIONS UNDER A COUNTER, AND ROTATES BY RENAME. The
|
|
12
|
+
# record is three files: `budget.tsv` (current generation), `budget.tsv.1`
|
|
13
|
+
# (previous) and `budget.gen`, a generation counter. A rotation renames the
|
|
14
|
+
# current file over the previous one; it never rewrites either.
|
|
15
|
+
#
|
|
16
|
+
# ROTATION BY RENAME, NOT BY WRITE-ASIDE, AND THE DIFFERENCE IS MEASURED. The
|
|
17
|
+
# port's `truncate()` read the live lines, wrote them aside and renamed the copy
|
|
18
|
+
# over the record: over a 46 MB ledger with four appenders writing 600 lines,
|
|
19
|
+
# **59 of the 600 appends were lost** in one 660 ms window. A line appended
|
|
20
|
+
# between the read and the rename is not in the kept set, and an appender whose
|
|
21
|
+
# `O_APPEND` descriptor opened the old inode writes into the unlinked file. A
|
|
22
|
+
# rename loses nothing, because an append that races it lands in one of the two
|
|
23
|
+
# files and the reader reads both: 11 of 11 trials, 600 of 600 visible. So
|
|
24
|
+
# `truncate()` is gone from the port and from the adapter.
|
|
25
|
+
#
|
|
26
|
+
# THE APPENDER NEVER WAITS. An appender that finds the lock held by a live owner
|
|
27
|
+
# appends and moves on — `O_APPEND` atomicity below `PIPE_BUF` is what makes the
|
|
28
|
+
# record lock-free, and an appender that blocked on a rotation would put a lock
|
|
29
|
+
# on plot's hot path to save a rename nobody is waiting for.
|
|
16
30
|
#
|
|
17
31
|
# WHY A SECOND IMPLEMENTATION OF A FORMAT THE DOMAIN ALREADY ENCODES. The
|
|
18
32
|
# spenders are eleven shell scripts, a board and a person at a terminal, and
|
|
@@ -120,6 +134,268 @@ budget_now_ms() {
|
|
|
120
134
|
printf '%s000\n' "$(date +%s)"
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
# ── Rotation ─────────────────────────────────────────────────────────────────
|
|
138
|
+
#
|
|
139
|
+
# THE GENERATION IS 24 HOURS AND EVERY WINDOW IS AT MOST ONE HOUR. The
|
|
140
|
+
# generation length is 24 times `BUDGET_FALLBACK_WINDOW_MS`, the upper bound of
|
|
141
|
+
# every spend window, so a line inside a live window is never in the generation a
|
|
142
|
+
# rotation discards. A contract test asserts that this exceeds the domain's
|
|
143
|
+
# `FALLBACK_WINDOW_MS`, so a longer window added later fails loudly rather than
|
|
144
|
+
# dropping live lines.
|
|
145
|
+
BUDGET_GENERATION_MS=86400000
|
|
146
|
+
|
|
147
|
+
# How long a lock may stand before it is stale. A rotation is two renames, so a
|
|
148
|
+
# lock older than this describes an owner that died rather than one still working.
|
|
149
|
+
BUDGET_LOCK_STALE_MS=10000
|
|
150
|
+
|
|
151
|
+
# The previous generation, the counter, and the lock, all beside the record.
|
|
152
|
+
budget_prev_path() { printf '%s\n' "$(budget_path).1"; }
|
|
153
|
+
budget_gen_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget.gen"; }
|
|
154
|
+
budget_lock_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget.lock"; }
|
|
155
|
+
budget_break_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget-lock-broken.tsv"; }
|
|
156
|
+
|
|
157
|
+
# The generation counter. A MISSING COUNTER READS AS 0, so the first release
|
|
158
|
+
# reads an unrotated ledger correctly — absence is the state of every machine
|
|
159
|
+
# that has not rotated yet, and reporting it as broken would make a working
|
|
160
|
+
# record look faulty. Anything that is not a number reads as 0 for the same
|
|
161
|
+
# reason: a torn counter must not stop a read.
|
|
162
|
+
budget_gen_read() {
|
|
163
|
+
local path value
|
|
164
|
+
path="$(budget_gen_path)" || { printf '0\n'; return 0; }
|
|
165
|
+
{ IFS= read -r value; } 2>/dev/null <"$path" || value=''
|
|
166
|
+
case "$value" in
|
|
167
|
+
''|*[!0-9]*) printf '0\n' ;;
|
|
168
|
+
*) printf '%s\n' "$value" ;;
|
|
169
|
+
esac
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Publishes a counter value. WRITTEN ASIDE AND RENAMED, so a reader never sees a
|
|
173
|
+
# partial number: a redirect creates the name before the content, and a reader
|
|
174
|
+
# that opened it between the two would read an empty counter as 0 and believe no
|
|
175
|
+
# rotation was in progress.
|
|
176
|
+
budget_gen_write() {
|
|
177
|
+
local value="${1:-0}" path scratch
|
|
178
|
+
path="$(budget_gen_path)" || return 1
|
|
179
|
+
mkdir -p "${path%/*}" 2>/dev/null || return 1
|
|
180
|
+
scratch="$path.$$.$BASHPID.tmp"
|
|
181
|
+
printf '%s\n' "$value" >"$scratch" 2>/dev/null || return 1
|
|
182
|
+
mv -f "$scratch" "$path" 2>/dev/null || { rm -f "$scratch" 2>/dev/null; return 1; }
|
|
183
|
+
return 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# The first line's timestamp of a generation, or nothing where it cannot be read.
|
|
187
|
+
#
|
|
188
|
+
# THE BRACES MATTER. `{ read …; } 2>/dev/null < "$file"` catches the redirection
|
|
189
|
+
# error; a trailing `2>/dev/null` on the `read` alone does not, and the moment
|
|
190
|
+
# after a rename is exactly when that redirection fails.
|
|
191
|
+
budget_first_at() {
|
|
192
|
+
local file="${1:-}" line at
|
|
193
|
+
{ IFS= read -r line; } 2>/dev/null <"$file" || return 1
|
|
194
|
+
at="$(printf '%s' "$line" | LC_ALL=C awk -F'\t' '{print $5}')"
|
|
195
|
+
case "$at" in
|
|
196
|
+
''|*[!0-9]*) return 1 ;;
|
|
197
|
+
esac
|
|
198
|
+
printf '%s\n' "$at"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Is the current generation older than one generation length?
|
|
202
|
+
#
|
|
203
|
+
# budget_rotation_due <now-ms>
|
|
204
|
+
#
|
|
205
|
+
# Exit 0 where a rotation is due. A record with no first line, no readable
|
|
206
|
+
# timestamp or no file at all is NOT due: absence is not age.
|
|
207
|
+
budget_rotation_due() {
|
|
208
|
+
local now="${1:-}" path at
|
|
209
|
+
path="$(budget_path)" || return 1
|
|
210
|
+
at="$(budget_first_at "$path")" || return 1
|
|
211
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
212
|
+
[ "$(( now - at ))" -ge "$BUDGET_GENERATION_MS" ]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# This process's owner line: pid and the moment it took the lock.
|
|
216
|
+
budget_owner_line() { printf '%s\t%s\n' "$$" "${1:-0}"; }
|
|
217
|
+
|
|
218
|
+
# The owner line inside a lock directory, or nothing.
|
|
219
|
+
budget_lock_owner() {
|
|
220
|
+
local dir="${1:-}" line
|
|
221
|
+
{ IFS= read -r line; } 2>/dev/null <"$dir/owner" || return 1
|
|
222
|
+
printf '%s\n' "$line"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Is this lock held by a live owner?
|
|
226
|
+
# 0 = held 1 = stale (dead pid, or older than the bound)
|
|
227
|
+
#
|
|
228
|
+
# A PID THE TABLE CANNOT BE ASKED ABOUT KEEPS ITS LOCK, `budget_slot_held`'s
|
|
229
|
+
# rule: breaking a lock on the strength of not knowing would rotate under a live
|
|
230
|
+
# rotator, and the loss that causes is the one rotation exists to avoid.
|
|
231
|
+
budget_lock_held() {
|
|
232
|
+
local dir="${1:-}" now="${2:-}" line pid at
|
|
233
|
+
line="$(budget_lock_owner "$dir")" || return 1
|
|
234
|
+
pid="${line%%$'\t'*}"; at="${line##*$'\t'}"
|
|
235
|
+
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
|
|
236
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
237
|
+
case "$at" in ''|*[!0-9]*) return 0 ;; esac
|
|
238
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
239
|
+
[ "$(( now - at ))" -lt "$BUDGET_LOCK_STALE_MS" ]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Breaks a stale lock, and takes only the lock it inspected.
|
|
243
|
+
#
|
|
244
|
+
# budget_lock_break <now-ms>
|
|
245
|
+
#
|
|
246
|
+
# RENAME FIRST, COMPARE SECOND. The breaker reads the owner line, renames the
|
|
247
|
+
# lock directory to a unique name, then reads the owner line inside the renamed
|
|
248
|
+
# directory and compares. Rename is atomic, so at most one process moves a given
|
|
249
|
+
# directory:
|
|
250
|
+
#
|
|
251
|
+
# - **Match**: it broke the stale lock. It records one line in
|
|
252
|
+
# `budget-lock-broken.tsv` and on stderr, removes the renamed directory, and
|
|
253
|
+
# repairs an odd counter.
|
|
254
|
+
# - **Mismatch**: it moved a FRESH owner's live lock. It records nothing and does
|
|
255
|
+
# not rotate; the fresh owner's pre-`mv` check of its own owner line then fails,
|
|
256
|
+
# so that rotator stops without renaming.
|
|
257
|
+
#
|
|
258
|
+
# Round 3 measured the unguarded version: two breakers both rotated, and 20
|
|
259
|
+
# live-window lines read as 0. Only the breaker whose line matched writes a
|
|
260
|
+
# record, so each break is recorded exactly once.
|
|
261
|
+
#
|
|
262
|
+
# DELETES ONLY A PATH IT HOLDS BY NAME. The renamed directory is unique to this
|
|
263
|
+
# process, and it is removed by that exact name — never a glob.
|
|
264
|
+
budget_lock_break() {
|
|
265
|
+
local now="${1:-}" lock before after moved rc=1
|
|
266
|
+
lock="$(budget_lock_path)" || return 1
|
|
267
|
+
[ -d "$lock" ] || return 1
|
|
268
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
269
|
+
|
|
270
|
+
before="$(budget_lock_owner "$lock")" || before=''
|
|
271
|
+
if budget_lock_held "$lock" "$now"; then return 1; fi
|
|
272
|
+
|
|
273
|
+
moved="$lock.broken.$$.${RANDOM}${RANDOM}"
|
|
274
|
+
mv "$lock" "$moved" 2>/dev/null || return 1
|
|
275
|
+
after="$(budget_lock_owner "$moved")" || after=''
|
|
276
|
+
|
|
277
|
+
if [ "$before" = "$after" ] && [ -n "$before" ]; then
|
|
278
|
+
local pid at age break_file
|
|
279
|
+
pid="${before%%$'\t'*}"; at="${before##*$'\t'}"
|
|
280
|
+
case "$at" in ''|*[!0-9]*) age='-' ;; *) age="$(( now - at ))" ;; esac
|
|
281
|
+
break_file="$(budget_break_path)" || break_file=''
|
|
282
|
+
if [ -n "$break_file" ]; then
|
|
283
|
+
printf 'b1\t%s\t%s\t%s\n' "$now" "$pid" "$age" >>"$break_file" 2>/dev/null || true
|
|
284
|
+
fi
|
|
285
|
+
echo "plot-budget: broke a stale rotation lock (pid=$pid age=${age}ms)" >&2
|
|
286
|
+
rc=0
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
rm -rf "$moved" 2>/dev/null || true
|
|
290
|
+
|
|
291
|
+
# AN ODD COUNTER IS REPAIRED ONLY BY THE BREAKER WHOSE LINE MATCHED. A
|
|
292
|
+
# mismatching breaker moved a live lock and knows nothing about the counter;
|
|
293
|
+
# writing it even would clear a rotation still in progress.
|
|
294
|
+
if [ "$rc" -eq 0 ]; then
|
|
295
|
+
local gen
|
|
296
|
+
gen="$(budget_gen_read)"
|
|
297
|
+
if [ $(( gen % 2 )) -eq 1 ]; then budget_gen_write "$(( gen + 1 ))" || true; fi
|
|
298
|
+
fi
|
|
299
|
+
return "$rc"
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Checks the lock and breaks it where it is stale. The one entry point for both
|
|
303
|
+
# triggers: an appender whose rotation is due finds the lock held, and any
|
|
304
|
+
# appender or reader that reads an ODD counter.
|
|
305
|
+
#
|
|
306
|
+
# THE SECOND TRIGGER IS WHY THIS IS NOT GATED ON A DUE ROTATION. A rotator killed
|
|
307
|
+
# after its `mv` and before its even write leaves a YOUNG `budget.tsv`, so no
|
|
308
|
+
# rotation is due for 24 h — and without this trigger the lock and the odd
|
|
309
|
+
# counter would stand for that long. Round 2 measured the consequence: 50 later
|
|
310
|
+
# appends, each due to rotate, never rotated.
|
|
311
|
+
budget_lock_recover() {
|
|
312
|
+
local now="${1:-}" lock
|
|
313
|
+
lock="$(budget_lock_path)" || return 1
|
|
314
|
+
[ -d "$lock" ] || return 1
|
|
315
|
+
budget_lock_break "$now"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Rotates the current generation, under the lock.
|
|
319
|
+
#
|
|
320
|
+
# budget_rotate <now-ms>
|
|
321
|
+
#
|
|
322
|
+
# Exit 0 where the rename happened. The sequence is a writer's half of a sequence
|
|
323
|
+
# lock: odd counter, rename, even counter.
|
|
324
|
+
#
|
|
325
|
+
# THE AGE CONDITION AND THE OWNER LINE ARE BOTH RE-READ IMMEDIATELY BEFORE THE
|
|
326
|
+
# `mv`. This one check makes a second rotation within a generation impossible,
|
|
327
|
+
# whoever holds the lock: the file a rotation leaves behind is new, so the age
|
|
328
|
+
# condition is false for it. Round 3 forced a second rotation 0.4 s after the
|
|
329
|
+
# first and 3 of 600 lines went in 5 of 5 trials — that is the discard this check
|
|
330
|
+
# refuses, and an implementation without it passes the one-rotation race.
|
|
331
|
+
#
|
|
332
|
+
# THE OWNER LINE GUARDS AGAINST A BREAKER. A breaker that moved this lock by
|
|
333
|
+
# mistake leaves no owner line to match, so a rotator whose lock was moved stops
|
|
334
|
+
# without renaming rather than rotating beside the process that took it.
|
|
335
|
+
budget_rotate() {
|
|
336
|
+
local now="${1:-}" lock path prev gen owner
|
|
337
|
+
path="$(budget_path)" || return 1
|
|
338
|
+
lock="$(budget_lock_path)" || return 1
|
|
339
|
+
prev="$(budget_prev_path)" || return 1
|
|
340
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
341
|
+
|
|
342
|
+
mkdir -p "${path%/*}" 2>/dev/null || return 1
|
|
343
|
+
# THE LOCK IS A `mkdir`, NEVER `flock`, which macOS does not ship. `mkdir`
|
|
344
|
+
# fails where the name is taken, which is the whole of the mutual exclusion.
|
|
345
|
+
mkdir "$lock" 2>/dev/null || return 1
|
|
346
|
+
|
|
347
|
+
owner="$(budget_owner_line "$now")"
|
|
348
|
+
printf '%s\n' "$owner" >"$lock/owner" 2>/dev/null || { rm -rf "$lock" 2>/dev/null; return 1; }
|
|
349
|
+
|
|
350
|
+
gen="$(budget_gen_read)"
|
|
351
|
+
# THE NEXT ODD NUMBER. A reader that sees it retries, and a reader that sees it
|
|
352
|
+
# with no live owner runs the stale-lock check.
|
|
353
|
+
if [ $(( gen % 2 )) -eq 1 ]; then gen=$(( gen + 1 )); fi
|
|
354
|
+
budget_gen_write "$(( gen + 1 ))" || { rm -rf "$lock" 2>/dev/null; return 1; }
|
|
355
|
+
|
|
356
|
+
local rc=1
|
|
357
|
+
if budget_rotation_due "$now" && [ "$(budget_lock_owner "$lock" 2>/dev/null || true)" = "$owner" ]; then
|
|
358
|
+
# O(1) AT ANY SIZE, and it loses no append: a writer that races this lands in
|
|
359
|
+
# one of the two files and the reader reads both.
|
|
360
|
+
if mv -f "$path" "$prev" 2>/dev/null; then rc=0; fi
|
|
361
|
+
fi
|
|
362
|
+
|
|
363
|
+
budget_gen_write "$(( gen + 2 ))" || true
|
|
364
|
+
# REMOVED ONLY WHERE THE LINE IS STILL THIS PROCESS'S. A lock a breaker moved
|
|
365
|
+
# belongs to whoever created the directory standing there now.
|
|
366
|
+
if [ "$(budget_lock_owner "$lock" 2>/dev/null || true)" = "$owner" ]; then
|
|
367
|
+
rm -rf "$lock" 2>/dev/null || true
|
|
368
|
+
fi
|
|
369
|
+
return "$rc"
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Rotates where one is due, and never makes its caller wait.
|
|
373
|
+
#
|
|
374
|
+
# budget_rotate_if_due <now-ms>
|
|
375
|
+
#
|
|
376
|
+
# Called by `budget_append` before it appends and by `budget_rate_read` before it
|
|
377
|
+
# reads. Both triggers live here: a due rotation whose lock is held runs the
|
|
378
|
+
# stale-lock check, and an odd counter runs it whether or not a rotation is due.
|
|
379
|
+
budget_maybe_rotate() {
|
|
380
|
+
local now="${1:-}" lock gen
|
|
381
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
382
|
+
lock="$(budget_lock_path)" || return 0
|
|
383
|
+
|
|
384
|
+
# TRIGGER TWO, AND IT IS FIRST because it covers the case no rotation is due
|
|
385
|
+
# for: a rotator killed after its `mv`.
|
|
386
|
+
gen="$(budget_gen_read)"
|
|
387
|
+
if [ $(( gen % 2 )) -eq 1 ]; then budget_lock_recover "$now" || true; fi
|
|
388
|
+
|
|
389
|
+
budget_rotation_due "$now" || return 0
|
|
390
|
+
|
|
391
|
+
if [ -d "$lock" ]; then
|
|
392
|
+
# TRIGGER ONE. A live owner keeps its lock and this appender moves on.
|
|
393
|
+
budget_lock_recover "$now" || return 0
|
|
394
|
+
fi
|
|
395
|
+
budget_rotate "$now" || return 0
|
|
396
|
+
return 0
|
|
397
|
+
}
|
|
398
|
+
|
|
123
399
|
# Appends one line: what a call spent, and what the response said.
|
|
124
400
|
#
|
|
125
401
|
# budget_append <connector> <account> <bucket> <spent> <limit> <remaining> <reset-seconds> <basis>
|
|
@@ -181,9 +457,20 @@ budget_append() {
|
|
|
181
457
|
fi
|
|
182
458
|
|
|
183
459
|
mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
|
|
460
|
+
|
|
461
|
+
# ROTATES BEFORE IT APPENDS, AND NEVER WAITS TO. `budget_maybe_rotate` returns 0
|
|
462
|
+
# on every path — a lock held by a live owner, a rotation that lost its race, a
|
|
463
|
+
# counter it could not write — so the append below happens either way. An
|
|
464
|
+
# appender that blocked on a rotation would put a lock on plot's hot path.
|
|
465
|
+
budget_maybe_rotate || true
|
|
466
|
+
|
|
184
467
|
# ONE `printf`, ONE `>>`. The redirection opens with `O_APPEND` and the single
|
|
185
468
|
# write is what the atomicity guarantee is about; two writes could interleave
|
|
186
469
|
# however short each was.
|
|
470
|
+
#
|
|
471
|
+
# AN APPEND THAT RACES THE RENAME IS NOT LOST. It lands in whichever file the
|
|
472
|
+
# descriptor resolved to, and the reader reads both generations: 11 of 11
|
|
473
|
+
# trials, 600 of 600 lines visible.
|
|
187
474
|
printf '%s\n' "$line" >>"$path" 2>/dev/null || true
|
|
188
475
|
return 0
|
|
189
476
|
}
|
|
@@ -221,30 +508,102 @@ BUDGET_FALLBACK_WINDOW_MS=3600000
|
|
|
221
508
|
# which is a reading about whichever pool was spent last — so a caller deciding
|
|
222
509
|
# whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
|
|
223
510
|
# and this is why.
|
|
511
|
+
# THE ZERO ANSWER, and it is a well-formed reading rather than a failure: a
|
|
512
|
+
# machine that has not spent has spent nothing.
|
|
513
|
+
BUDGET_ZERO_ANSWER='{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown","read":0}'
|
|
514
|
+
|
|
515
|
+
# READS TWO GENERATIONS UNDER THE COUNTER, like a sequence lock:
|
|
516
|
+
#
|
|
517
|
+
# 1. Read `budget.gen` as `g1`. An ODD value means a rotation is in progress, or
|
|
518
|
+
# a rotator died — run the stale-lock check, then retry.
|
|
519
|
+
# 2. Read `budget.tsv`, then `budget.tsv.1`, THROUGH ONE PIPE.
|
|
520
|
+
# 3. Read `budget.gen` as `g2`. A value that differs from `g1` means a rotation
|
|
521
|
+
# landed during the read; discard the answer and retry.
|
|
522
|
+
#
|
|
523
|
+
# THE ORDER IN STEP 2 IS DELIBERATE: CURRENT FIRST, THEN PREVIOUS. A rotation
|
|
524
|
+
# between the two reads makes the reader see the old current file twice — it
|
|
525
|
+
# counts that generation twice and never misses it, and the counter check then
|
|
526
|
+
# discards the answer. The reverse order skips the whole live generation: round 2
|
|
527
|
+
# measured `spent` 0 for it.
|
|
528
|
+
#
|
|
529
|
+
# NO FILE NAME IS EVER PASSED TO `awk`. BSD awk 20200816 and gawk both exit 2
|
|
530
|
+
# before `END` on a missing input file, and the `|| echo` fallback below would
|
|
531
|
+
# turn that into spent 0 — the direction that GRANTS headroom. `.1` is missing on
|
|
532
|
+
# every machine until its first rotation and `budget.tsv` is missing after each
|
|
533
|
+
# rotation until the next append, so both are normal states and each reads as
|
|
534
|
+
# empty through `{ cat …; cat …; } 2>/dev/null`.
|
|
535
|
+
#
|
|
536
|
+
# AFTER THREE RETRIES IT ANSWERS FROM THE LAST READ AND SAYS SO. The answer
|
|
537
|
+
# carries `"rotating":true` and can only OVER-count, which makes
|
|
538
|
+
# `graphql_budget_spent` more cautious and never less.
|
|
539
|
+
BUDGET_READ_RETRIES=3
|
|
540
|
+
|
|
224
541
|
budget_rate_read() {
|
|
225
542
|
local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
|
|
226
|
-
local path
|
|
543
|
+
local path prev answer g1 g2 attempt=0
|
|
227
544
|
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
228
|
-
path="$(budget_path)" || { echo
|
|
545
|
+
path="$(budget_path)" || { echo "$BUDGET_ZERO_ANSWER"; return 0; }
|
|
546
|
+
prev="$(budget_prev_path)"
|
|
229
547
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
548
|
+
while : ; do
|
|
549
|
+
g1="$(budget_gen_read)"
|
|
550
|
+
if [ $(( g1 % 2 )) -eq 1 ]; then
|
|
551
|
+
# A ROTATION IS IN PROGRESS, OR A ROTATOR DIED. The check is the same one
|
|
552
|
+
# an appender runs, and it is what stops an odd counter standing for 24 h.
|
|
553
|
+
budget_lock_recover "$now" || true
|
|
554
|
+
if [ "$attempt" -lt "$BUDGET_READ_RETRIES" ]; then
|
|
555
|
+
attempt=$(( attempt + 1 ))
|
|
556
|
+
continue
|
|
557
|
+
fi
|
|
558
|
+
fi
|
|
559
|
+
|
|
560
|
+
answer="$(budget_rate_pass "$connector" "$account" "$bucket" "$now" "$path" "$prev")"
|
|
561
|
+
|
|
562
|
+
g2="$(budget_gen_read)"
|
|
563
|
+
if [ "$g2" = "$g1" ] && [ $(( g2 % 2 )) -eq 0 ]; then
|
|
564
|
+
printf '%s\n' "$answer"
|
|
565
|
+
return 0
|
|
566
|
+
fi
|
|
567
|
+
|
|
568
|
+
if [ "$attempt" -ge "$BUDGET_READ_RETRIES" ]; then
|
|
569
|
+
# THE LAST READ, MARKED. It can only over-count, because a rotation during
|
|
570
|
+
# the read makes the live generation read twice and never skipped.
|
|
571
|
+
printf '%s,"rotating":true}\n' "${answer%\}}"
|
|
572
|
+
return 0
|
|
573
|
+
fi
|
|
574
|
+
attempt=$(( attempt + 1 ))
|
|
575
|
+
done
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
# One pass over both generations. The `awk` half of the reader; the counter check
|
|
579
|
+
# above decides whether its answer is kept.
|
|
580
|
+
budget_rate_pass() {
|
|
581
|
+
local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
|
|
582
|
+
local path="${5:-}" prev="${6:-}"
|
|
237
583
|
|
|
238
|
-
|
|
584
|
+
# `|| true` ON EACH `cat`, AND `pipefail` IS WHY. A missing generation is the
|
|
585
|
+
# normal state — `.1` until the first rotation, `budget.tsv` between a rotation
|
|
586
|
+
# and the next append — and under `set -o pipefail`, which `plot-host.sh` sets,
|
|
587
|
+
# a failing `cat` fails the WHOLE pipeline however well `awk` answered. The
|
|
588
|
+
# `|| echo` fallback below then fires beside a perfectly good answer and the
|
|
589
|
+
# caller reads TWO JSON objects: measured, `plot-host.sh spend-rate` printed
|
|
590
|
+
# the same object twice and `JSON.parse` refused it. The redirection silences
|
|
591
|
+
# the message; only this silences the status.
|
|
592
|
+
{ cat -- "$path" || true; cat -- "$prev" || true; } 2>/dev/null | LC_ALL=C awk -v want_c="$connector" -v want_a="$account" -v want_b="$bucket" \
|
|
239
593
|
-v now="$now" -v fallback="$BUDGET_FALLBACK_WINDOW_MS" '
|
|
240
|
-
BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1 }
|
|
594
|
+
BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1; total = 0 }
|
|
241
595
|
{
|
|
242
596
|
# A NULL IS THE NORMAL CASE, not an error. The file is appended to by
|
|
243
597
|
# processes that may be killed mid-write, so a torn tail, a blank line and
|
|
244
598
|
# a line from a newer format are all things a reader meets — and every one
|
|
245
599
|
# is skipped rather than thrown on. A reader that failed on one bad line
|
|
246
600
|
# would report the whole account as unreadable, which reads as headroom.
|
|
601
|
+
# WHAT THE PASS READ, ACROSS BOTH GENERATIONS, counted before any filter:
|
|
602
|
+
# the `read` field is the bound this reader reports, and the bound is about
|
|
603
|
+
# the FILE rather than about one key. It replaced a timing claim, which
|
|
604
|
+
# measured machine load rather than the ledger.
|
|
247
605
|
if ($0 == "") next
|
|
606
|
+
total++
|
|
248
607
|
if (NF != 10 || $1 != "b1") { unreadable++; next }
|
|
249
608
|
if ($2 != want_c || $3 != want_a) next
|
|
250
609
|
if (want_b != "" && $4 != want_b) next
|
|
@@ -315,10 +674,10 @@ budget_rate_read() {
|
|
|
315
674
|
# cadence input this slice exists to make honest.
|
|
316
675
|
rate = "null"
|
|
317
676
|
}
|
|
318
|
-
printf "{\"spent\":%d,\"spanMs\":%d,\"perHour\":%s,\"lines\":%d,\"unreadable\":%d,\"limit\":%s,\"remaining\":%s,\"resetAt\":%s,\"basis\":\"%s\"}\n", \
|
|
319
|
-
spent, span, rate, n, unreadable, limit, remaining, reset, basis
|
|
677
|
+
printf "{\"spent\":%d,\"spanMs\":%d,\"perHour\":%s,\"lines\":%d,\"unreadable\":%d,\"limit\":%s,\"remaining\":%s,\"resetAt\":%s,\"basis\":\"%s\",\"read\":%d}\n", \
|
|
678
|
+
spent, span, rate, n, unreadable, limit, remaining, reset, basis, total
|
|
320
679
|
}
|
|
321
|
-
'
|
|
680
|
+
' 2>/dev/null || echo "$BUDGET_ZERO_ANSWER"
|
|
322
681
|
}
|
|
323
682
|
|
|
324
683
|
# THE SAME ANSWER IS SCANNED FOR ONCE PER PROCESS, and that is the whole of this
|