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
4 files+61−120/4 viewed
| 429 | 429 | | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null }; | |
| 430 | 430 | ||
| 431 | 431 | 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; | |
| 432 | 439 | const PRICE_SECONDS = 10 * 60; | |
| 433 | 440 | ||
| 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 | + | ||
| 434 | 447 | export class ComputeGate { | |
| 435 | 448 | private ents = new Map<string, { value: ComputeEntitlements; until: number }>(); | |
| 436 | 449 | private price: { value: number; until: number } | null = null; | |
| 472 | 485 | try { | |
| 473 | 486 | const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug })); | |
| 474 | 487 | 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 }); | |
| 476 | 489 | await this.memory.put(slug, ent.plan); | |
| 477 | 490 | return ent; | |
| 478 | 491 | } catch (error) { |
| 229 | 229 | }) | |
| 230 | 230 | } | |
| 231 | 231 | ||
| 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)> { | |
| 235 | 236 | let now = now_ms(); | |
| 236 | 237 | let kept = ALLOWANCES.with(|kept| kept.borrow().get(workspace).copied()); | |
| 237 | 238 | if let Some((allowance, at)) = kept | |
| 239 | + | && !fresh | |
| 238 | 240 | && now.saturating_sub(at) < ALLOWANCE_TTL_MS | |
| 239 | 241 | { | |
| 240 | − | return Some(allowance); | |
| 242 | + | return Some((allowance, true)); | |
| 241 | 243 | } | |
| 242 | 244 | let billing = self.env.service("BILLING").ok()?; | |
| 243 | 245 | let asked: Result<quota::Allowance> = g1t_kit::call( | |
| 249 | 251 | match asked { | |
| 250 | 252 | Ok(allowance) => { | |
| 251 | 253 | ALLOWANCES.with(|kept| kept.borrow_mut().insert(workspace.to_owned(), (allowance, now))); | |
| 252 | − | Some(allowance) | |
| 254 | + | Some((allowance, false)) | |
| 253 | 255 | } | |
| 254 | 256 | Err(error) => { | |
| 255 | 257 | worker::console_error!("packages: billing could not be asked about {workspace}, letting the push through: {error}"); | |
| 276 | 278 | if adding == 0 { | |
| 277 | 279 | return Ok(None); | |
| 278 | 280 | } | |
| 279 | − | let Some(allowance) = self.allowance(&package.workspace).await else { | |
| 281 | + | let Some((allowance, kept)) = self.allowance(&package.workspace, false).await else { | |
| 280 | 282 | return Ok(None); | |
| 281 | 283 | }; | |
| 282 | 284 | let (public_bytes, private_bytes) = self.db.storage(&package.workspace).await?; | |
| 283 | 285 | let public = package.public(); | |
| 284 | 286 | 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))) | |
| 288 | 298 | } | |
| 289 | 299 | ||
| 290 | 300 | fn count_download(&self, package_id: &str, ctx: &Context) { |
| 163 | 163 | /// How long counts read from the database are gone by. Every write of the | |
| 164 | 164 | /// meters reads them again (meters.rs), so a busy workspace's are seconds old. | |
| 165 | 165 | 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. | |
| 167 | 170 | 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 | + | } | |
| 168 | 177 | ||
| 169 | 178 | thread_local! { | |
| 170 | 179 | static STANDING: RefCell<HashMap<String, Standing>> = RefCell::new(HashMap::new()); | |
| 277 | 286 | pub async fn is_free_kept(billing: Option<&Fetcher>, namespace: &str) -> bool { | |
| 278 | 287 | let now = g1t_kit::now_ms(); | |
| 279 | 288 | 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) | |
| 281 | 290 | }); | |
| 282 | 291 | if let Some(free) = kept { | |
| 283 | 292 | return free; | |
| 332 | 341 | use super::*; | |
| 333 | 342 | ||
| 334 | 343 | #[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] | |
| 335 | 352 | fn operations_are_counted_by_the_hour() { | |
| 336 | 353 | assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09"); | |
| 337 | 354 | } |
| 12 | 12 | agentEstimateMicros, | |
| 13 | 13 | alwaysPasses, | |
| 14 | 14 | embeddingEstimateMicros, | |
| 15 | + | entitlementsKeptSeconds, | |
| 15 | 16 | isWaiting, | |
| 16 | 17 | issueCapReached, | |
| 17 | 18 | localRefusal, | |
| 133 | 134 | assert.equal(asked.some((call) => call.method === "reserve"), false); | |
| 134 | 135 | }); | |
| 135 | 136 | ||
| 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 | + | ||
| 136 | 145 | test("internal and enterprise plans pass even when billing refuses", async () => { | |
| 137 | 146 | for (const plan of ["internal", "enterprise"] as const) { | |
| 138 | 147 | const { binding } = billing({ |