Replace the three upload Workflows with inline retries and throttling#13
Merged
Conversation
Cloudflare are introducing per-step billing for Workflows. The weather station reports roughly every 30 seconds and each observation created three workflow instances (Windguru, Met Office, Windy), which is around 255k instances a month - expensive, and more machinery than the job needs. Each of those workflows was a single step.do() wrapping one fetch, purely to get durable retry, and the observation is already saved to D1 before the workflow is created. The uploads now run inline under ctx.waitUntil with their own retry loop: three attempts spread over ~18 seconds, five second timeout each. That is slightly less patient than the workflows were, but retries no longer outlive the invocation and a failed observation is superseded ~30 seconds later anyway. Each service is also throttled to its own minimum interval - two minutes for Windguru, five for Met Office and Windy - using a new upload_state table in D1. Windy rejects anything sent inside five minutes, so most of its uploads were previously created only to be discarded. This cuts outbound calls from ~8,500 a day to ~1,300 and removes nearly all of the rate limit rejections. upload_state also records the last success and last error per service, which replaces the visibility the Workflows dashboard used to give. D1 is used rather than KV for the throttle state because KV reads are cached for a minimum of 60 seconds (which would break a two minute throttle) and the free plan only allows 1,000 KV writes a day. The overnight save to R2 workflow is unchanged - it runs a handful of times a day. Note that the three workflows remain registered in the Cloudflare account until deleted there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5mSoK49qAEDmWw1xdRJyt
The dateutc value was escaped by hand and then escaped again by URLSearchParams, so what WOW actually received was the literal string "2026-07-26+12%3A00%3A00" rather than a timestamp. Pass the plain "YYYY-mm-DD HH:mm:ss" value and let URLSearchParams do the escaping, which is what WOW expects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5mSoK49qAEDmWw1xdRJyt
Jbithell
marked this pull request as ready for review
July 26, 2026 17:24
There was a problem hiding this comment.
Pull request overview
This PR removes the three “upload” Cloudflare Workflows (Windguru / Met Office / Windy) and replaces them with inline uploads executed under ctx.waitUntil, adding local retry/backoff logic and per-target throttling backed by a new D1 upload_state table to reduce workflow instance costs and outbound requests.
Changes:
- Remove the three upload workflow bindings and workflow entrypoint classes; keep
OvernightSaveToR2unchanged. - Add inline upload modules for Windguru/Met Office/Windy with a shared
withRetryhelper and fetch timeouts. - Add
upload_stateD1 table + throttle/dispatch logic to skip uploads until each target’s minimum interval elapses.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| website/wrangler.jsonc | Removes the three upload workflow bindings to stop new workflow instances. |
| website/workers/app.ts | Deletes the three upload workflow entrypoints; keeps scheduled workflow behavior. |
| website/app/routes/uploadFromWeatherStation.ts | Switches from creating three workflows to a single inline dispatchObservation call in waitUntil. |
| website/workers/uploads/index.ts | New dispatch/throttling orchestration and persistence of attempt/success/error state. |
| website/workers/uploads/retry.ts | New local retry/backoff helper and non-retryable error type. |
| website/workers/uploads/windguru.ts | New inline Windguru uploader with timeout and non-retryable credential failure. |
| website/workers/uploads/metoffice.ts | New inline Met Office uploader with dateutc fix and timeout/backoff handling. |
| website/workers/uploads/windy.ts | New inline Windy uploader with timeout/backoff handling. |
| website/database/schema/UploadState.ts | Adds Drizzle schema/types for upload_state and uploadTargets. |
| website/database/schema.d.ts | Re-exports UploadState schema/types. |
| website/database/migrations/0004_wealthy_wildside.sql | Adds the upload_state table. |
| website/database/migrations/meta/0004_snapshot.json | Drizzle migration snapshot for the new table. |
| website/database/migrations/meta/_journal.json | Records the new migration in the migration journal. |
| website/worker-configuration.d.ts | Regenerated Wrangler types reflecting removed workflow bindings and updated runtime types. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| windspeedmph: data.data.windSpeed.toString(), // real number [mph]; wind speed (alternative to wind) | ||
| winddir: data.data.windDirection.toString(), // integer number [deg]; instantaneous wind direction | ||
| //windgustmph: data.data.windGust.toString(), // real number [mph]; current wind gust (alternative to gust) | ||
| dewpoint: data.data.dewPoint.toString(), // real number [°C]; |
Comment on lines
+64
to
+77
| const now = new Date(); | ||
| const state = await db.select().from(schema.UploadState); | ||
| const lastAttemptByTarget = new Map( | ||
| state.map((row) => [row.target, row.lastAttemptAt]), | ||
| ); | ||
|
|
||
| const dueTargets = schema.uploadTargets.filter((target) => { | ||
| const lastAttemptAt = lastAttemptByTarget.get(target); | ||
| if (!lastAttemptAt) return true; | ||
| return ( | ||
| now.getTime() - lastAttemptAt.getTime() >= | ||
| TARGETS[target].minimumIntervalMs | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Cloudflare are introducing per-step billing for Workflows. The weather station reports roughly every 30 seconds, and every observation created three workflow instances — Windguru, Met Office and Windy — which is around 255k instances a month on the free plan.
Each of those workflows was a single
step.do()wrapping onefetch, existing purely to get durable retry. The observation is already saved to D1 before the workflow is created, so a failed upload only ever costs a data point on a third-party site.OvernightSaveToR2is untouched — it runs a handful of times a day.What changed
Uploads run inline under
ctx.waitUntil. The three uploader bodies moved verbatim intowebsite/workers/uploads/{windguru,metoffice,windy}.ts— same URLs, same params, same MD5 salt scheme, same success/backoff detection.NonRetryableErrorfromcloudflare:workflowsbecame a localNonRetryableUploadError, and the step'stimeout: "5 seconds"becameAbortSignal.timeouton each fetch.withRetryinworkers/uploads/retry.tsdoes 3 attempts spread over ~18s (0s, +3s, +15s), giving a ~33s worst case. That is less patient than the workflows' 3 attempts over ~3 minutes, but retries can no longer outlive the invocation — and the next observation arrives ~30s later regardless. Sleeping costs no CPU time, which matters against the free plan's 10ms per-invocation limit.Each service is throttled to its own minimum interval: Windguru 2 minutes (matching the
interval=120already sent), Met Office and Windy 5 minutes. Windy rejects anything sent inside 5 minutes, so most of its uploads were previously created only to be discarded. This cuts outbound calls from ~8,500/day to ~1,300/day and removes nearly all of the rate-limit rejections. The trade-off is fewer data points on the three public sites.New
upload_stateD1 table (migration0004_wealthy_wildside.sql) holdslast_attempt_at,last_success_atandlast_errorper service. The attempt timestamp is written before the uploads run, so an observation arriving while a slow retry is still in flight can't dispatch to the same service again.last_erroralso replaces the visibility the Workflows dashboard used to give.D1 rather than KV for this state: KV reads are cached for a minimum of 60 seconds, which would break a 2-minute throttle, and the free plan allows only 1,000 KV writes/day against the ~1,300 this needs.
Bug fix: double-encoded
dateutcThe Met Office
dateutcvalue was escaped by hand (.replace("T","+").replace(/:/g,"%3A")) and then escaped again byURLSearchParams, so WOW was receiving the literal string2026-07-26+12%3A00%3A00instead of a timestamp. It now passes the plainYYYY-mm-DD HH:mm:ssvalue and letsURLSearchParamsdo the escaping:dateutc=2026-07-26%2B12%253A00%253A002026-07-26+12%3A00%3A00dateutc=2026-07-26+12%3A00%3A002026-07-26 12:00:00Verification
Ran locally against
react-router devwith a stub standing in for the three services:last_success_atset,last_errorclearedNonRetryableUploadErrorshort-circuits the retry looplast_errorrecordeddateutcafter the fix"2026-07-26 12:00:00"from an observation timestamped2026-07-26T12:00:00.000ZThe outgoing query strings were diffed against what the workflows sent and are identical apart from the
dateutcfix above.npm run buildpasses.npm run types:checkreports only the pre-existingapp/root.tsx(73,10)process.enverror, which is untouched by this branch.Note
Removing the three entries from
wrangler.jsoncstops new instances but leaves the workflows registered in the Cloudflare account — they need deleting there separately. Two orphans from an earlier refactor (PSCWeather-HandleReceivedObservation,PSCWeather-DisregardReceivedObservation) appear to still be registered too.