@@ -11,6 +11,157 @@ target.
1111
1212## [ Unreleased]
1313
14+ ## [ 3.2.0] — 2026-10-05
15+
16+ Redis Cluster and Redis over TLS now work, so managed Redis services can be
17+ used. Usage limits work as documented: token limits refuse, an API key's
18+ limits count on that key, and a refused request counts against nothing.
19+ Server instances can start together against one database, a restart no
20+ longer clears captured bodies older than 30 days, and the Helm network
21+ policy allows the ports the database URLs name. The thinkwatch-core crates
22+ stay at v0.62.0.
23+
24+ ### Read before upgrading
25+
26+ - ** Rate-limit windows start empty.** Rate-limit counters move to new Redis
27+ keys (one hash per counter, tagged so that Redis Cluster can run them), and
28+ the counts from before the upgrade are not carried over: every window starts
29+ empty and fills from the first request after the upgrade. The old keys
30+ expire by themselves within two window lengths. Users' budget counters are
31+ kept; API keys' are not (see below).
32+ - ** Route health starts fresh.** A route's samples, circuit breaker and
33+ lifetime request count move to new keys for the same reason, so every route
34+ starts closed with nothing counted. The old lifetime counters never expire;
35+ ` redis-cli --scan --pattern 'route_health:[0-9a-f]*' | xargs redis-cli del `
36+ removes them (the new keys start ` route_health:{ ` ).
37+ - ** An API key's own budgets start again.** 3.1.0 counted a key's budget on
38+ its owner's counter (` budget:user:<user id>:… ` ); 3.2.0 counts it on the
39+ key's own (` budget:api_key_lineage:<lineage id>:… ` ), which starts at zero.
40+ A key with a monthly budget of its own can spend all of it again in the
41+ rest of the month.
42+ - ** Limits are looser while the rollout runs.** Pods of 3.1.0 and 3.2.0
43+ count rate limits and route health on different keys, so each sees only
44+ its own version's requests: limits let more through, and circuit breakers
45+ can disagree, until the last 3.1.0 pod is gone.
46+ - ** Token limits start refusing.** A ` tokens ` rate limit never refused a
47+ request before. It now does once its window is full, so a deployment with
48+ token limits will see ` 429 ` s where it saw none.
49+ - ** A key's limits no longer replace its owner's.** A rate limit or budget set
50+ on an API key used to take the place of the owner's limit for the same
51+ window or period. Both now apply, each on its own counter: a key's limits can
52+ narrow what its owner may do through that key, never widen it. A key given a
53+ higher limit than its owner to give it more room needs the owner's limit
54+ raised instead.
55+ - ** ` rediss:// ` connects over TLS.** 3.1.0 connected to a ` rediss:// ` URL
56+ over plain TCP. A ` rediss:// ` URL pointing at a port without TLS now fails
57+ at start: point it at the TLS port, or write ` redis:// ` .
58+ - The certificate must name the host in a subjectAltName. One that names
59+ it only in its CN, which ` redis-cli ` accepts, is refused.
60+ - Cluster nodes that announce IP addresses their certificates don't name
61+ can't be reached.
62+ - ** ` budget_unavailable ` in dashboards.** With Redis down and
63+ ` security.rate_limit_fail_closed ` on, a request that has a budget is
64+ refused as ` budget_unavailable ` , not ` rate_limiter_unavailable ` : budgets
65+ are checked first.
66+ - ** Upgrade with an ordinary rollout.** Earlier versions don't take the lock
67+ that now makes instances set up the schema one at a time, so don't restart
68+ instances of the old version while the first one of this version starts.
69+ - ** No transaction-mode pooler in front of Postgres.** Schema setup now holds a
70+ Postgres session-level advisory lock, which a pooler in transaction mode
71+ (PgBouncer ` pool_mode = transaction ` ) can leave held, and every later start
72+ then waits for it: point ` DATABASE_URL ` at Postgres itself or at a pooler in
73+ session mode (the Helm chart's README has the details).
74+
75+ ### Fixed
76+
77+ - ** Token limits refuse requests.** A ` tokens ` rate limit never refused
78+ anything, and stopped counting once a request would have taken it past its
79+ limit. A request is now refused once the window's recorded usage reaches the
80+ limit, and every request's tokens are recorded after it, even past the limit
81+ — a window can overshoot by what was in flight when it filled.
82+ - ** Several request limits at once.** With two or more ` requests ` limits on a
83+ user (per minute and per hour, say), every request that passed was counted
84+ twice, and only one of the limits could refuse; with
85+ ` security.rate_limit_fail_closed ` on, every request was refused as
86+ ` rate_limiter_unavailable ` .
87+ - ** An API key's limits count on that key.** They were counted on its owner's
88+ counter, which every key of the owner shared, and the usage the console
89+ reads for a key (` /api/admin/limits/api_key/{id}/usage ` ) was always 0. Each
90+ key now has counters of its own, rate limits and budgets alike, for the
91+ gateway and the MCP gateway, and its usage shows what it used.
92+ - ** A refused request counts against nothing.** A request refused by a spent
93+ budget, or by one rate limit after another had passed, was still counted
94+ against the request limits. Budgets are now checked first and every rate
95+ limit in one step, so a refused request leaves every counter as it was.
96+ - ** ` Retry-After ` says when to retry.** A ` 429 ` from the gateway's own limits
97+ said ` Retry-After: 30 ` whatever the limit. It now gives the seconds until the
98+ window has room for another request, or until a spent budget's period ends
99+ (the next midnight, Monday or 1st of the month, UTC). A spent budget also
100+ sends ` x-should-retry: false ` , so the OpenAI and Anthropic SDKs don't retry it
101+ by themselves. The body stays in the caller's API format.
102+ - ** Redis Cluster.** The rate-limit, route-health and quota scripts touched
103+ keys of several hash slots, which a Redis Cluster refuses: on a cluster, rate
104+ limits silently stopped applying (or refused every request with
105+ ` security.rate_limit_fail_closed ` ), circuit breakers never tripped, and cache
106+ invalidation reached one node only. Every key a script touches now shares a
107+ hash tag, pattern deletes scan every node, and the Helm chart's README
108+ describes a ` redis-cluster:// ` URL.
109+ - ** Redis over TLS.** The server was built without TLS for Redis: a
110+ ` rediss:// ` URL was used as plain TCP, so against a Redis that requires
111+ TLS — ElastiCache with in-transit encryption, Upstash, Azure Cache for
112+ Redis and Redis Cloud among them — the server did not start
113+ (` Failed to connect to Redis: Protocol Error: Expected string. ` ).
114+ ` rediss:// ` and ` rediss-cluster:// ` URLs now connect over TLS and check
115+ the certificate against the system's CAs, as upstream HTTPS does. For a
116+ Redis whose certificate a private CA signed, ` REDIS_CA_CERT ` names a PEM
117+ file with that CA, which is then trusted alone; the Helm chart sets it
118+ from a Secret given in ` redis.caSecret ` . The chart's README describes
119+ both.
120+ - ** Several instances starting at once.** Server instances starting together
121+ against one database — a Helm ` replicaCount ` above 1, a rolling upgrade, an
122+ autoscaler adding pods — applied the schema side by side, and all but one
123+ could exit with `Database migration failed: apply db/schema.sql: … deadlock
124+ detected` (on an empty database: ` duplicate key value violates unique
125+ constraint "pg_extension_name_index"`). With ClickHouse, the rollups that an
126+ instance fills from the logs when it finds them empty (` cost_rollup_hourly ` ,
127+ ` provider_health_5m ` , ` mcp_server_call_counts ` ) could be filled by each of
128+ them, counting every request once per instance on the cost pages, the
129+ dashboard and the MCP server list. Instances now set up Postgres and
130+ ClickHouse one at a time, under Postgres advisory locks: the others wait,
131+ logging `Another instance is setting up the database schema; waiting for it
132+ to finish`, then find it done. An instance that dies holding a lock releases
133+ it with its connection. The Helm chart's startup probe allows a pod 125 s
134+ to start instead of 35 s, set in ` startupProbe ` (the chart's README says
135+ when to raise it), and an attempt to reach a ClickHouse that doesn't answer
136+ gives up after 5 s instead of the system's TCP timeout.
137+ - ** Captured bodies kept as long as configured.** With ClickHouse and
138+ ` audit.body_retention_days ` above 30, every server start could clear the
139+ captured request and response bodies older than 30 days
140+ (` gateway_logs.request_body ` / ` response_body ` , ` mcp_logs.tool_arguments `
141+ / ` tool_result ` ; the rows themselves stayed). The start-up table setup set
142+ those columns' TTL to 30 days each time, and ClickHouse applies a TTL to
143+ the data already stored as soon as it is set, before the server put the
144+ configured TTL back a moment later. The setup now gives these columns a
145+ TTL only when it creates them, so a restart leaves the configured one in
146+ place. Bodies already cleared cannot be recovered. The log tables' own
147+ TTLs (` data.retention_days_* ` ) were not affected.
148+ - ** Helm network policy and databases on other ports.** With
149+ ` networkPolicy.enabled ` , the server could reach PostgreSQL only on ` 5432 ` ,
150+ Redis on ` 6379 ` and ClickHouse on ` 8123 ` , whatever their ` externalUrl ` said,
151+ so a database on another port was blocked — Azure Cache for Redis over TLS
152+ (` 6380 ` ), a managed Postgres or a ClickHouse on a port of its own: the
153+ server could not start, or started without writing to ClickHouse. The
154+ allowed ports now follow ` postgres.externalUrl ` , ` redis.externalUrl ` and
155+ ` clickhouse.externalUrl ` : every port a URL names, and the client's default
156+ for its scheme where it names none. ` networkPolicy.extraEgress ` adds egress
157+ rules as written, for ports no URL names (Redis Cluster nodes announcing
158+ other ports, an upstream or MCP server on a port other than ` 443 ` ). The
159+ chart's README describes both. Port ` 9000 ` , ClickHouse's native protocol,
160+ is no longer allowed: the server reaches ClickHouse over plain HTTP only
161+ (an ` http:// ` URL; HTTPS is not supported). An S3 endpoint on ` 9000 `
162+ (RustFS, MinIO) configured outside the chart needs a rule in
163+ ` networkPolicy.extraEgress ` .
164+
14165## [ 3.1.0] — 2026-10-05
15166
16167The thinkwatch-core crates move from v0.59.0 to v0.62.0. Two changes reach
@@ -1180,7 +1331,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql`
11801331against PostgreSQL, restart against this tag. The schema is
11811332idempotent end-to-end, so the apply is safe to repeat.
11821333
1183- [ Unreleased ] : https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.1.0...HEAD
1334+ [ Unreleased ] : https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.2.0...HEAD
1335+ [ 3.2.0 ] : https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.2.0
11841336[ 3.1.0 ] : https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.1.0
11851337[ 3.0.0 ] : https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.0.0
11861338[ 2.2.0 ] : https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.2.0
0 commit comments