Skip to content

Commit

Compute, git and packages: an answer that holds a workspace back (paused, free, past its storage) is checked again within seconds, so adding a plan or resuming takes effect on the next press instead of minutes later

syntaqxcommitted Parentb0a8df9Browse files
4 files+61−120/4 viewed
+14−1
429429 | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null };
430430
431431 const ENTITLEMENTS_SECONDS = 30;
432+/**
433+ * An answer that holds compute back (paused, or a free workspace without
434+ * compute) is kept only a few seconds: the owner who just resumed it or
435+ * added a plan presses Run next, and must not be refused by a copy from
436+ * before they did.
437+ */
438+const HOLDING_BACK_SECONDS = 3;
432439 const PRICE_SECONDS = 10 * 60;
433440
441+/** How long the gate keeps an entitlements answer, in seconds. */
442+export function entitlementsKeptSeconds(ent: ComputeEntitlements): number {
443+ const holdsBack = Boolean(ent.paused) || (ent.plan === "free" && !ent.compute);
444+ return holdsBack ? HOLDING_BACK_SECONDS : ENTITLEMENTS_SECONDS;
445+}
446+
434447 export class ComputeGate {
435448 private ents = new Map<string, { value: ComputeEntitlements; until: number }>();
436449 private price: { value: number; until: number } | null = null;
472485 try {
473486 const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug }));
474487 if (!ent) throw new Error("entitlements did not say the workspace's plan");
475− this.ents.set(slug, { value: ent, until: Date.now() + ENTITLEMENTS_SECONDS * 1000 });
488+ this.ents.set(slug, { value: ent, until: Date.now() + entitlementsKeptSeconds(ent) * 1000 });
476489 await this.memory.put(slug, ent.plan);
477490 return ent;
478491 } catch (error) {
+19−9
229229 })
230230 }
231231
232− /// What billing allows the workspace, kept for five minutes. `None`
233− /// when billing cannot be asked: the push is then let through.
234− async fn allowance(&self, workspace: &str) -> Option<quota::Allowance> {
232+ /// What billing allows the workspace, kept for five minutes unless
233+ /// `fresh`, and whether it is the kept answer. `None` when billing
234+ /// cannot be asked: the push is then let through.
235+ async fn allowance(&self, workspace: &str, fresh: bool) -> Option<(quota::Allowance, bool)> {
235236 let now = now_ms();
236237 let kept = ALLOWANCES.with(|kept| kept.borrow().get(workspace).copied());
237238 if let Some((allowance, at)) = kept
239+ && !fresh
238240 && now.saturating_sub(at) < ALLOWANCE_TTL_MS
239241 {
240− return Some(allowance);
242+ return Some((allowance, true));
241243 }
242244 let billing = self.env.service("BILLING").ok()?;
243245 let asked: Result<quota::Allowance> = g1t_kit::call(
249251 match asked {
250252 Ok(allowance) => {
251253 ALLOWANCES.with(|kept| kept.borrow_mut().insert(workspace.to_owned(), (allowance, now)));
252− Some(allowance)
254+ Some((allowance, false))
253255 }
254256 Err(error) => {
255257 worker::console_error!("packages: billing could not be asked about {workspace}, letting the push through: {error}");
276278 if adding == 0 {
277279 return Ok(None);
278280 }
279− let Some(allowance) = self.allowance(&package.workspace).await else {
281+ let Some((allowance, kept)) = self.allowance(&package.workspace, false).await else {
280282 return Ok(None);
281283 };
282284 let (public_bytes, private_bytes) = self.db.storage(&package.workspace).await?;
283285 let public = package.public();
284286 let used = if public { public_bytes } else { private_bytes };
285− Ok(quota::decide(&allowance, public, used, adding)
286− .err()
287− .map(|refusal| quota::message(&package.workspace, &refusal)))
287+ let mut refused = quota::decide(&allowance, public, used, adding).err();
288+ // A refusal from the kept answer is checked with billing again: the
289+ // workspace may have just added a plan, and must not wait minutes
290+ // for the push to go through.
291+ if refused.is_some() && kept {
292+ refused = match self.allowance(&package.workspace, true).await {
293+ Some((allowance, _)) => quota::decide(&allowance, public, used, adding).err(),
294+ None => None,
295+ };
296+ }
297+ Ok(refused.map(|refusal| quota::message(&package.workspace, &refusal)))
288298 }
289299
290300 fn count_download(&self, package_id: &str, ctx: &Context) {
+19−2
163163 /// How long counts read from the database are gone by. Every write of the
164164 /// meters reads them again (meters.rs), so a busy workspace's are seconds old.
165165 const STANDING_TTL_MS: u64 = 10 * 60 * 1000;
166−/// How long billing's answer about a workspace's plan is kept.
166+/// How long billing's answer about a workspace's plan is kept: a paid
167+/// plan for minutes, a free one (which slows the workspace down) for
168+/// seconds, so a workspace that just added a plan is not held back for
169+/// minutes by an answer from before it did.
167170 const PLAN_TTL_MS: u64 = 5 * 60 * 1000;
171+const FREE_PLAN_TTL_MS: u64 = 30 * 1000;
172+
173+/// Whether a plan answer read at `at` (`free` or not) still goes at `now`.
174+fn plan_kept(free: bool, at: u64, now: u64) -> bool {
175+ now.saturating_sub(at) < if free { FREE_PLAN_TTL_MS } else { PLAN_TTL_MS }
176+}
168177
169178 thread_local! {
170179 static STANDING: RefCell<HashMap<String, Standing>> = RefCell::new(HashMap::new());
277286 pub async fn is_free_kept(billing: Option<&Fetcher>, namespace: &str) -> bool {
278287 let now = g1t_kit::now_ms();
279288 let kept = FREE.with(|free| {
280− free.borrow().get(namespace).filter(|(_, at)| now.saturating_sub(*at) < PLAN_TTL_MS).map(|(free, _)| *free)
289+ free.borrow().get(namespace).filter(|(free, at)| plan_kept(*free, *at, now)).map(|(free, _)| *free)
281290 });
282291 if let Some(free) = kept {
283292 return free;
332341 use super::*;
333342
334343 #[test]
344+ fn a_free_plan_is_asked_again_within_a_minute_a_paid_one_kept_longer() {
345+ assert!(plan_kept(true, 1_000, 1_000 + FREE_PLAN_TTL_MS - 1));
346+ assert!(!plan_kept(true, 1_000, 1_000 + FREE_PLAN_TTL_MS));
347+ assert!(plan_kept(false, 1_000, 1_000 + FREE_PLAN_TTL_MS));
348+ assert!(!plan_kept(false, 1_000, 1_000 + PLAN_TTL_MS));
349+ }
350+
351+ #[test]
335352 fn operations_are_counted_by_the_hour() {
336353 assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09");
337354 }
+9−0
1212 agentEstimateMicros,
1313 alwaysPasses,
1414 embeddingEstimateMicros,
15+ entitlementsKeptSeconds,
1516 isWaiting,
1617 issueCapReached,
1718 localRefusal,
133134 assert.equal(asked.some((call) => call.method === "reserve"), false);
134135 });
135136
137+test("an answer that holds compute back is kept seconds, one that lets it through half a minute", () => {
138+ assert.equal(entitlementsKeptSeconds(ent()), 30);
139+ assert.equal(entitlementsKeptSeconds(ent({ paused: "A spend spike is waiting for an owner." })), 3);
140+ assert.equal(entitlementsKeptSeconds(ent({ plan: "free", compute: false })), 3);
141+ // A free workspace with compute (its trial) runs, so it is kept as long.
142+ assert.equal(entitlementsKeptSeconds(ent({ plan: "free", compute: true })), 30);
143+});
144+
136145 test("internal and enterprise plans pass even when billing refuses", async () => {
137146 for (const plan of ["internal", "enterprise"] as const) {
138147 const { binding } = billing({