Commit Graph
4253 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 854869ba53 Coordinate Craft App release polling across the fleet through Redis (#77)
New releases took up to ~30 min to reach \`GET
/v1/craft_apps/release_info\` (ArtCraft Launcher 0.2.0 needed 17–29
min), pods disagreed with each other, and every pod polled GitHub on its
own, which uses up the unauthenticated quota (60/h per IP, and 304s
count) and risks backing off for up to 4 h.

The pods now share their results through Redis. There's no lock:
- **One Redis hash per app** (\`craftApps:releases:app:<key>\`, 7 d
TTL). It holds the latest release, the ETag and \`fetched_ms\`. Before
asking GitHub about an app, a pod reads its entry. If it's fresher than
\`interval − 30 s\` (a clock-skew margin), the pod takes it from Redis
and doesn't call GitHub.
- **Last write wins, guarded by timestamp.** A Lua script never lets an
entry go back in time. A 304 moves only \`fetched_ms\`, and only while
the stored ETag still matches.
- **A soft per-app claim** (60 s, no owner, it just lapses), so a fleet
that ticks every 30 s rarely fetches the same app twice.
- **Backoff shared by the whole fleet**
(\`craftApps:releases:backoff\`), so a rate limit slows everyone down.
Per-app failure retry is 60 s, doubling.
- **The endpoint is unchanged.** It still serves each pod's in-memory
copy. If Redis is down, a pod falls back to polling on its own and warns
at most every 10 minutes.

**The GitHub token is optional.** With
\`CRAFT_APPS_RELEASE_GITHUB_TOKEN\` set, the default interval is 120 s
(min 60). Without it (unset or blank), calls go out unauthenticated with
no Authorization header, at 1800 s (min 300), and startup and polling
are never blocked. \`CRAFT_APPS_RELEASE_COORDINATION_ENABLED=false\`
switches back to the old per-pod polling.

**Tests:** \`cargo test -p storyteller-web --bin storyteller-web craft\`
(49 pass) and \`cargo test -p redis_schema craft\` (4 pass). The Redis
tests passed against a local redis-server with
\`CRAFT_APPS_TEST_REDIS_URL=… -- --include-ignored --skip live_round\`
(51 pass).

**Follow-ups after review:**
- **The poller has its own Redis pool** (2 connections; connects within
3 s; every read and write, including the pool's `PING` check, times out
after 5 s). Before this, it shared the handlers' pool, which has no read
timeout, so a Redis that stopped answering could leave the poller
holding every connection while handlers waited 30 s for one. A test
against a fake Redis that goes silent after the handshake fails without
the timeouts and passes with them.
- **When Redis won't run scripts**, writes fall back to
`WATCH`/`MULTI`/`EXEC`: read and decode the guarding fields, decide, and
write in a transaction that Redis drops if another pod wrote in between.
This switch only happens when Redis refuses the script itself. Being
unreachable, busy, out of memory or read-only doesn't trigger it. It was
tested against a real Redis user denied `@scripting`.
- **New pods load from Redis at startup**, before the startup jitter, so
they serve the fleet's releases right away without asking GitHub.
- **`Cache-Control: no-store` until every app has been fetched**
(`last_fetched` set), so Cloudflare never caches a cold pod's "not
fetched yet" answer. The response body is unchanged.

**Tests:** `cargo test -p storyteller-web --bin storyteller-web craft --
--include-ignored --skip live_round` against a local Redis 8.6: 63 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:05:06 -04:00
Brandon ThomasandClaude Opus 5.5 f37f82b400 Serve Craft App release info for ArtCraft Launcher (#71)
GET /v1/craft_apps/release_info reports the latest release of every
Craft App (and ArtCraft Launcher itself), so launchers stop polling
GitHub directly. A background thread polls GitHub and keeps a shared
cache.

Polling (threads/poll_craft_app_releases_thread):
- One round at startup (after 0–30 s of jitter, so a fleet deploy is
spread out), then every CRAFT_APPS_RELEASE_POLL_INTERVAL_SECS (30 min
default, 5 min minimum) with ±10% jitter
- One request per app (GET /repos/{repo}/releases/latest) with ETag
revalidation and 1 s between requests
- Rate limits stop the round and wait for GitHub's reset, backing off
2×, 4×, … the interval up to CRAFT_APPS_RELEASE_MAX_BACKOFF_SECS (4 h);
rounds where every request failed retry after 1, 2, 4… min, never more
often, up to the interval
- Per app all-or-nothing: either its release and app_last_fetched are
stored, or nothing about it changes
- CRAFT_APPS_RELEASE_POLLING_ENABLED, CRAFT_APPS_RELEASE_GITHUB_TOKEN
and CRAFT_APPS_RELEASE_APPS (key=Name=org/repo,…) configure it

Cache (state/craft_apps_release_cache): Arc<RwLock<BTreeMap<..>>> of
typed entries. release_info.last_fetched is the oldest app_last_fetched,
absent until every app has been fetched. Apps are listed from the start,
without a release or app_last_fetched until fetched.

The endpoint never fails, sets Cache-Control: public, max-age=60, and
returns empty app_renames, repo_renames and important_notices for now.
API types live in artcraft_api_defs::craft_apps::release_info, with each
asset's OS / arch / package type parsed from its file name. reqwest
moves from a dev-dependency to a dependency of storyteller-web.

Tests: cache freshness, asset-name parsing for every published file
type, untrusted-answer filtering, backoff, and an ignored live round
against GitHub that also writes a sample the launcher's parser is tested
against.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:35:45 -04:00
Brandon ThomasandClaude Opus 5.5 c489e8c650 Cap MySQL statement cache and connection lifetime in storyteller-web (#65)
## Problem

Production `storyteller-web` and the job workers started returning fast
500s with MySQL error 1461:

```
Can't create more than max_prepared_stmt_count statements (current value: 16382)
```

A customer saw this on `GET /v1/prompt/batch`. Errors rose to ~316/hour
on 2026-10-09 and stopped after a rolling restart at ~17:50 UTC. The
restart only clears the caches, so this will happen again under load.

## Cause

sqlx (0.7.4) caches up to **100** prepared statements per connection by
default, and each cached statement stays open on the server. At current
scale, `storyteller-web` can hold:

| Setting                       | Before   | After   |
|-------------------------------|----------|---------|
| Replicas                      | 7        | 7       |
| `MYSQL_MAX_CONNECTIONS`       | 50       | 50      |
| Statement cache / connection  | 100      | 25      |
| Max statements held           | ~35,000  | ~8,750  |
| Server cap                    | 16,382   | 16,382  |

The ~70 call sites that use `QueryBuilder` (variable-length `IN (...)`
lists) produce many different SQL strings, which fill the caches faster.

## Changes

`crates/service/web/storyteller_web/src/configs/connect_to_database.rs`:

- **`statement_cache_capacity`** defaults to 25, configurable with
`MYSQL_STATEMENT_CACHE_CAPACITY`.
- **`max_lifetime`** defaults to 10 minutes (sqlx's default is 30),
configurable with `MYSQL_MAX_LIFETIME_SECONDS`. A query cancelled
between prepare and close (e.g. the client disconnects) leaves its
statement open on the server until the connection closes. Recycling
connections sooner frees those statements sooner.
- Both values are logged with the existing pool settings at startup.

Job workers are left unchanged: their pools are hardcoded to 2
connections, so each holds at most 200 statements.

## Notes

- If replicas or `MYSQL_MAX_CONNECTIONS` increase, keep `replicas ×
MYSQL_MAX_CONNECTIONS × MYSQL_STATEMENT_CACHE_CAPACITY` well under
16,382.
- Follow-up (not in this PR): use `.persistent(false)` on hot dynamic
`QueryBuilder` queries such as `batch_get_prompts`.

## Testing

- `SQLX_OFFLINE=true cargo check -p storyteller-web` passes.
- After deploy, look for the new values in the `Building MySQL pool:`
startup log line, and confirm no new `max_prepared_stmt_count` errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:20 -04:00
Brandon Thomas 2e596cc4b3 ffmpeg fallback on mac 2026-09-28 01:07:45 -04:00
Brandon Thomas c3d72fb321 Generate WebP thumbnails for videos instead of Gif (except: MacOS 10.15)
MacOS Catalina 10.15 released October 7, 2019 can't do gifs in
safari/webkit.
2026-09-28 00:52:07 -04:00
Brandon Thomas 46fd907182 additional cors tests to prevent regressions 2026-09-27 23:43:06 -04:00
Brandon Thomas 6b34f23b43 tests 2026-09-27 22:55:36 -04:00
Brandon Thomas 7a88118349 fold cdn configs in 2026-09-27 22:41:01 -04:00
Brandon Thomas e07bf35cd0 static config for first party domains 2026-09-27 22:40:14 -04:00
Brandon Thomas b9ee1bd225 fixes to new endpoint 2026-09-27 22:01:01 -04:00
Brandon Thomas 68886b62fe Merge pull request #37 from storytold/bt-query-perf
more performant list user files endpoint
2026-09-27 20:22:51 -04:00
Brandon Thomas 3a93e358cd tests 2026-09-27 17:27:40 -04:00
Brandon Thomas 1cb0b32898 move desktop configs 2026-09-27 17:25:20 -04:00
Brandon Thomas c50bb78c32 tauri cors for new domain 2026-09-27 17:12:27 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
Brandon Thomas b6a184bcb0 enum variants 2026-09-26 02:36:24 -07:00
Brandon Thomas d0e1d113fe break out queries 2026-09-26 02:13:23 -07:00
Brandon Thomas 1facb312dc tests 2026-09-26 01:57:16 -07:00
Brandon Thomas f2c8a0390f final fixes 2026-09-26 00:46:19 -07:00
Brandon Thomas 2e83d39d2b login challenge on website works 2026-09-26 00:22:45 -07:00
Brandon Thomas 4f1c9134ee WIP SSO OTP bridge 2026-09-25 23:23:00 -07:00
Brandon Thomas 1b2d2f49e5 improve concurrency 2026-09-20 10:44:54 -04:00
Brandon Thomas 3e6d759fe2 fix thumbnail job latency 2026-09-20 10:12:38 -04:00
Brandon Thomas e2da58b3e5 re-route request 2026-09-20 08:21:08 -04:00
Brandon Thomas cfa8264642 debug tool 2026-09-18 18:54:10 -04:00
Brandon Thomas 734b757ef9 gpt image 2.5: new qualities 2026-09-18 14:12:34 -04:00
Brandon Thomas 92bc1e24c4 wan 3.0 + prime 2026-09-18 13:40:50 -04:00
Brandon Thomas d5a2b36525 promo pricing 2026-09-18 12:09:19 -04:00
Brandon Thomas e3c553ff2c wan 3.0 client bindings 2026-09-18 12:00:58 -04:00
Brandon Thomas 6a35fccba3 standardize / cleanup video batch sizes 2026-09-18 09:45:53 -04:00
Brandon Thomas db925450c5 video batching 2026-09-18 09:36:01 -04:00
Brandon Thomas 90f1fcb25f prices 2026-09-18 09:17:00 -04:00
Brandon Thomas 0245058e05 tests 2026-09-18 09:12:50 -04:00
Brandon Thomas 5d311b7a20 increase batch counts 2026-09-18 09:07:06 -04:00
Brandon Thomas 2a43d929ec update margins 2026-09-17 12:10:31 -04:00
Brandon Thomas 437bbadd0f seedance 1080p price is going up 2026-09-17 11:35:24 -04:00
Brandon Thomas dab29e1cc0 don't turn off audio for seedance 2026-09-16 17:27:24 -04:00
Brandon Thomas 70eec108fa gate quicktime uploads 2026-09-15 19:11:41 -04:00
Brandon Thomas 7dd5331232 hide behind feature flag 2026-09-15 18:48:36 -04:00
Brandon Thomas d368abe867 advertise mov feature flag, frontend UX 2026-09-15 16:22:19 -04:00
Brandon Thomas ef2710fcc8 wip 2026-09-14 15:40:33 -04:00
Brandon Thomas ad08682610 support seedance 2.5 quicktime output 2026-09-14 15:40:32 -04:00
Brandon Thomas 9c1633bd9c high bitrate seedance 2.5 2026-09-12 13:22:42 -04:00
Brandon Thomas cb1bea9d0a remove the desktop app from the repo 2026-09-12 12:23:46 -04:00
Brandon Thomas 4c890b790c bump artcraft to 0.37 (#1942) 2026-09-11 01:04:54 -04:00
Brandon Thomas 7b88737c45 fix tauri for omni apis (#1941) 2026-09-11 01:03:53 -04:00
Brandon ThomasandHanashi 7fb81500a7 rename CLAUDE.md to AGENTS.md (#1940)
Co-authored-by: Hanashi <shared2@storyteller.ai>
2026-09-10 21:55:17 -04:00
Brandon ThomasandHanashi 74207e3520 gpt-image-2.5 (#1937)
Co-authored-by: Hanashi <shared2@storyteller.ai>
2026-09-09 16:37:35 -04:00
Brandon ThomasandHanashi 60be62cb71 fix fal client error (#1929)
Co-authored-by: Hanashi <shared2@storyteller.ai>
2026-09-08 18:47:53 -04:00
hanashi 0fa098dd38 more providers (#1922) 2026-09-01 10:45:02 -04:00