Prewarming Pool System
Prewarming Pool System
Design document for the Genesis application and workspace prewarming system. This system maintains pools of fully-provisioned applications and workspaces that can be instantly handed over to users, eliminating multi-minute provisioning wait times.
Problem Statement
Creating a Genesis application or workspace involves a lengthy provisioning pipeline:
- Application creation (~5-10 min): Provisions a StudioEnv (Kubernetes pod), extracts template files, starts dev/live apps, initializes Code and Agent services.
- Workspace creation (~5-10 min): Same pipeline but also includes git clone for git-configured applications.
Users experience this as a cold-start delay every time they create a new application or workspace. The prewarming system eliminates this by maintaining a pool of pre-created resources ready for instant assignment.
Architecture
The system has two modular components, each solving a distinct use case:
| Component | Use Case | Pool Key |
|---|---|---|
PreprovisionedApplication | User creates app from a template (blankPkg, genaiPkg, etc.) | Per template |
PreprovisionedWorkspace | User creates workspace in a git-configured application | Per application |
A shared Pool Controller (CronJob) runs every minute to reconcile pool sizes and clean up stale entries.
System Context Diagram
┌─────────────────────────────────────────┐
│ Pool Controller (CronJob) │
│ genesis-preprovisioning-reconciler │
│ Runs every minute │
└──────────┬──────────────┬───────────────┘
│ │
┌───────────────▼──┐ ┌─────▼───────────────┐
│ Application Pool │ │ Workspace Pool │
│ (per template) │ │ (per application) │
│ │ │ │
│ blankPkg x3 │ │ App-A x2 │
│ genaiPkg x2 │ │ App-B x3 │
└────────┬─────────┘ └─────────┬───────────┘
│ │
┌────────────────▼──────┐ ┌─────────────▼─────────────┐
│ Template.createApp() │ │ Workspace.createWithPre() │
│ tries claim first │ │ tries claim first │
└────────────────┬──────┘ └─────────────┬─────────────┘
│ │
┌───────▼─────────┐ ┌───────▼──────────────┐
│ User gets app │ │ User gets workspace │
│ (instant) │ │ + git checkout │
└─────────────────┘ └──────────────────────┘Data Model
Entity Types
Genesis.PreprovisionedApplication
Tracks a single pre-provisioned application in the warm pool.
| Field | Type | Description |
|---|---|---|
template | !Genesis.Template | Template this was provisioned from |
application | Genesis.Application | The pre-created application |
status | enum Status | PROVISIONING, READY, CLAIMING, CLAIMED, ERROR, EXPIRED |
claimedBy | User | User who claimed (null if unclaimed) |
claimedAt | datetime | When claimed |
claimDurationMs | int | End-to-end duration of the claim operation |
createdAt | !datetime | When provisioning started |
readyAt | datetime | When reached READY |
errorMessage | string | Error details if failed |
DB indexes: [application, status], [status]
Pool Configuration Types
Both pools are configured via separate PoolConfig entity types:
Genesis.PreprovisionedApplication.PoolConfig (per template):
template: Which template to prewarmpoolSize: Desired number of READY entriesmaxAgeHours: Expiry threshold (default 48h)enabled: Toggle
Genesis.PreprovisionedWorkspace.PoolConfig (per application):
application: Which git-app to prewarm workspaces forpoolSize: Desired number of READY entriesmaxAgeHours: Expiry threshold (default 24h)enabled: Toggle
Status Lifecycle
┌──────────────┐
│ PROVISIONING │ ─── (reconciler promotes) ───► READY ──── (privileged claim) ────► CLAIMING
└──────────────┘ │ │
│ │ (maxAge exceeded) ┌─────────┴─────────┐
│ (failure or 30m timeout) ▼ │ │
▼ EXPIRED (success) (failure)
ERROR │ │
│ ▼ ▼
│ (24h) CLAIMED ERROR
▼
[removed]State transitions:
PROVISIONING → READY: Reconciler detects underlying workspace is configured.READY → CLAIMING:Genesis.PrivilegedAction.claimWorkspace/claimApplicationacquires optimistic lock (race-safe, up to 3 retry attempts on version conflict).CLAIMING → CLAIMED: Ownership transfer, env admin registration, and git checkout all succeeded.CLAIMING → ERROR: Downstream claim work failed;errorMessagerecords the failure reason. Terminal — requires operator investigation.READY → EXPIRED: Entry exceedsmaxAgeHours.PROVISIONING → ERROR: Provisioning failure or 30-minute timeout.
Core Algorithms
1. Reconciliation (every minute)
The Genesis.Preprovisioning.reconcilePools() function:
- Log pool status for observability (ready/provisioning/errored counts per pool)
- Promote ready entries: Check PROVISIONING entries whose underlying workspace has reached READY or ERROR state, and promote them accordingly. Also timeout entries stuck in PROVISIONING for 30+ minutes with no linked resource.
- Reconcile application pools: For each enabled PoolConfig, compute
deficit = poolSize - readyCount - provisioningCount. SubmitdeficitAsyncActions toprovisionNew. - Reconcile workspace pools: Same logic per application.
- Cleanup expired: Mark stale READY entries as EXPIRED, terminate their environments. Remove ERROR entries older than 24h.
Promotion runs first so that deficit calculations reflect entries that just finished provisioning, preventing over-provisioning.
2. Claim (on user request)
When a user creates an application or workspace, Genesis.PrivilegedAction.claimWorkspace or claimApplication is called as a privileged action (@action(authz='always')):
- Retry loop (up to 3 attempts): Re-fetches the oldest READY entry on each iteration for a fresh version.
- Atomically update status to
CLAIMINGviamerge()(optimistic locking). If the merge fails (version conflict / race condition), retry with the next available entry. - Single privileged lambda (
StudioCluster.executePrivilegedJsLambda): All ownership-transfer writes run in a singleCtx.asRoot()context:- Merge
Genesis.Application(rename, set admins/users, clearisPreprovisioned, setmeta.createdBy), then callGenesis.Application.populateAcl. - For each workspace: merge
Genesis.Workspace(admins/users, clearisPreprovisioned, setmeta.createdBy), callGenesis.Workspace.populateAcl. - Transfer StudioEnv admins inline: filter out old createdBy user from admins/users, add claiming user to admins/users/createdBy/updatedBy, call
StudioEnv.populateAcl. - Cascade to child StudioApps: same inline admin transfer pattern, call
StudioApp.populateAcl.
- Merge
- Post-lambda (as calling user): For each workspace found via
Genesis.Workspace.fetchby application ID, sethibernationEnabled = truevia config, re-fetch workspace, and calldoSyncWorkspaceEnvAdminsto register the user in the running environment's C3.EnvAdmin group. - Git checkout (workspace claims): Check out the user's requested branch.
- On success: Update status to
CLAIMED, recordclaimDurationMs, return the claimed entity. - On failure: Update status to
ERROR, store the error inerrorMessage, re-throw so the caller falls back to normal creation.
If no READY entry exists (pool exhausted) or all retry attempts are lost to race conditions, the system seamlessly falls back to normal creation. The user experience is identical either way.
The privileged action eliminates the need for isPreprovisioned == true write data permissions on C3.Code.User — users only need read access to discover preprovisioned entries, and the claim action handles all writes with elevated privileges.
3. Provisioning (non-blocking, background)
Provisioning is split into two phases to avoid thread starvation:
Phase 1 — provisionNew (fast, releases thread immediately):
- Creates a PreprovisionedApplication/Workspace entry with status=PROVISIONING.
- Creates the actual Application/Workspace with:
- A placeholder display name (
prewarmed <uuid-prefix>) - An explicit
pkgNameof the formpkg<uuid-prefix>(e.g.pkg6306adba). This is the on-disk folder name and is set here — at the moment the folder is created — so it stays stable for the lifetime of the application.pkg<uuid-prefix>is used instead of the display-name-derivedprewarmed<uuid>because (a) it is a valid C3 package identifier by construction (starts with a letter, no spaces, no hyphens) and (b) it does not leak the "prewarmed" origin into the package identity, which users see in deployment artifacts and repo layouts. isPreprovisioned: trueflag (bypasses user quota checks)
- A placeholder display name (
- Links the created resource to the entry via
merge(). - Returns immediately — does NOT wait for the workspace to reach READY.
On failure, sets status=ERROR with the error message.
Phase 2 — promoteReady (in the reconciler, every minute):
- Fetches all PROVISIONING entries that have a linked application/workspace.
- Checks the workspace's current state:
- If
READY→ promotes the entry to READY. - If
ERROR→ marks the entry as ERROR. - Otherwise → leaves it in PROVISIONING (will re-check next cycle).
- If
- Times out entries stuck in PROVISIONING for 30+ minutes with no linked resource (creation failed silently).
This two-phase design ensures provisionNew never holds an async action thread longer than a few seconds. The underlying configureWorkspace action (which actually provisions the Kubernetes pod and makes the workspace READY) runs independently and gets threads freely.
5. Ownership Transfer
All ownership transfer logic runs inside a single StudioCluster.executePrivilegedJsLambda call, executing in Ctx.asRoot():
For applications:
- Renames application to user's desired display name (
namefield only) - Does NOT rewrite
pkgName— the package name is set at provisioning (pkg<uuid-prefix>) and is the on-disk folder name; rewriting it would desync from disk. See On-disk name stability. - Sets the user's description
- Clears
isPreprovisionedflag on the application and its workspaces - Sets the claiming user as admin/user on Genesis.Application and Genesis.Workspace
- Updates
meta.createdByon all affected entities - Calls
populateAclon each modified entity to immediately refresh ACL caches - Transfers StudioEnv admins inline: filters out old createdBy user from admins/users, adds claiming user to admins/users/createdBy/updatedBy
- Cascades StudioEnv transfer to all child StudioApps (same inline pattern)
- Post-lambda: re-enables hibernation on all workspaces, re-fetches each workspace, and calls
doSyncWorkspaceEnvAdminsto register the user in the running environment
For workspaces:
- Renames to user's desired name
- Clears
isPreprovisionedflag - Sets the claiming user as admin/user
- Updates
meta.createdBy - Calls
populateAclon the workspace - Transfers StudioEnv admins inline (same pattern as above)
- Cascades to child StudioApps
- Post-lambda: re-enables hibernation, re-fetches workspace, calls
doSyncWorkspaceEnvAdmins, then performs git checkout via agent service
CronJobs
The preprovisioning system uses a single CronJob created by Genesis.Preprovisioning.enableReconciler():
| CronJob ID | Purpose | Interval | Action |
|---|---|---|---|
genesis-preprovisioning-reconciler | Pool reconciliation, promotion, cleanup | Every minute | reconcilePools |
The reconciler uses runAsCreatedBy: true, meaning it executes as the admin user who called enableReconciler(). This is critical because the reconciler needs ClusterAdmin permissions for env.callJson() calls during provisioning.
// Run once after deployment as a ClusterAdmin user:
Genesis.Preprovisioning.enableReconciler()Integration Points
Modified: Genesis.Template.createApplication()
Before: Always calls Genesis.Application.make({...}).create()
After: First tries Genesis.PrivilegedAction.claimApplication(templateId, name, desc)
Falls back to normal creation if claim returns nullModified: Genesis.Workspace.createWorkspace()
Before: Only Genesis.Workspace.make(spec).create() existed
After: New static method that tries Genesis.PrivilegedAction.claimWorkspace() for git apps
Falls back to Genesis.Workspace.make(spec).create()
Also validates user quota (canCreateWorkspace)Modified: Genesis.Application.beforeCreate() / Genesis.Workspace.beforeCreate()
Added isPreprovisioned check that bypasses:
- User quota validation (
canCreateApplication,canCreateWorkspace) - Name regex validation (placeholder names like "prewarmed abc123" contain spaces)
- Admin/user assignment (system creates these without a real user context)
The isPreprovisioned field is declared private to prevent external API callers from setting it.
For preprovisioned applications, pkgName is set explicitly by provisionNew to pkg<uuid-prefix> (see Provisioning above) rather than being derived from the placeholder display name. beforeCreate therefore leaves pkgName untouched when it is already set — the derivation-from-name fallback is retained only for the non-preprovisioned path where no explicit pkgName is supplied. See On-disk name stability below.
Modified: Genesis.Workspace.configureWorkspace()
After a preprovisioned workspace finishes configuration, both Genesis-level and Studio-level hibernation are explicitly disabled:
hibernationEnabled = false— prevents the GenesisstopInactiveWorkspacescron from stopping it.StudioCluster.Hibernation.setPolicyForSenvs([envId], 'InactivityNoPolicy')— prevents the Studio platform's own inactivity monitor from hibernating the pod.
The isPreprovisioned field is fetched explicitly via this.get('isPreprovisioned').isPreprovisioned rather than accessed directly on this. This is necessary because configureWorkspace is dispatched via AsyncAction, which does not reliably hydrate fields on the this reference — even with @dependency annotations.
isPreprovisioned Flag Lifecycle
The isPreprovisioned flag on both Application and Workspace is transient — it is only true while the resource is owned by the pool system:
| Phase | isPreprovisioned | hibernationEnabled | Studio Hibernation Policy |
|---|---|---|---|
| Provisioning | true | true (default) | Default (active) |
| Ready (in pool) | true | false (disabled after configure) | InactivityNoPolicy (disabled) |
| Claimed (transferred to user) | false | true (re-enabled) | Default (active) |
On claim, the privileged lambda clears the flag and the post-lambda code re-enables Genesis-level hibernation so the resource behaves as a normal user-owned entity — subject to standard quota checks, inactivity timeouts, and all other policies. The Studio-level policy is not explicitly reset on claim; the next resume cycle sets InactivityNoPolicy (see resumeWorkspace), and the Genesis-level stopInactiveWorkspaces takes over as the primary inactivity mechanism for claimed workspaces.
On-disk name stability (pkgName)
Problem. The DB field Genesis.Application.pkgName serves two purposes:
- Identity — the platform-visible package name (deployment artifact names,
Pkg.Storelookups, on-disk manifest<pkgName>.c3pkg.json). - Location — where the package lives on disk (
/usr/workspace/${pkgsPath}/${pkgName}/…) and therefore what valueVITE_C3_PKGmust have so the UI service can locateui/react/.
The two are fundamentally the same fact: the on-disk folder name is the package identity in a C3 workspace. They can safely be represented by a single field — provided that field never diverges from what is on disk. Historically, transferOwnership rewrote pkgName on claim (to camelCase(newName)) but could not rename the on-disk folder (platform limitation). While the pod stayed up the drift was invisible; on workspace resume the pod is re-created and getGenesisNodePoolPlanConfig re-templates env vars from the current DB state — the new UI pod boots with VITE_C3_PKG set to the user-facing camelCased name, waitForUiDir polls a path that will never exist, and the pod hangs in appPhase = 'waiting' with appMessage = 'Waiting for UI directory: <path>' indefinitely.
Solution. Keep pkgName as the single source of truth for both roles and make it stable by construction — never rewrite it after the folder has been created on disk.
provisionNewsetspkgNameexplicitly topkg<uuid-prefix>(e.g.pkg6306adba). This is a valid C3 package identifier by construction (starts with a letter, no spaces, no hyphens) and does not leak the "prewarmed" origin into the package name that users see in deployment artifacts and repo layouts. The display name (namefield) still usesprewarmed <uuid-prefix>for internal observability while the entry is in the pool, but thepkgNameand on-disk folder arepkg<uuid-prefix>from the moment they are created.transferOwnershipno longer touchespkgName. It updates the user-facingnameanddescription, clearsisPreprovisioned, and swaps admins/users — but the package identity is fixed at provisioning time. The user's chosen display name lives inname;pkgNameremains the immutablepkg<uuid-prefix>that matches the on-disk folder.Genesis.Application#getRootPkgName(currentlyreturn pkgName || name) remains the single accessor. BecausepkgNameis now always populated at create for both preprovisioned and normal paths, the|| namefallback is effectively dead code but is left in place for defence-in-depth on legacy rows.
The pod-template pipeline in Genesis.Workspace.js#getGenesisNodePoolPlanConfig is unchanged in shape — the existing ${rootPkgName} template variable now correctly resolves to the on-disk folder name on every code path (initial create, first configuration, resume). Both VITE_C3_PKG (UI container) and MAIN_PKG_NAME (coding-agent container) in config/StudioNodePoolPlanConfig/c3app.json continue to read ${rootPkgName} — no config change is required.
Backfill. Applications claimed under previous code have pkgName that no longer matches the on-disk folder. A one-shot migration is required:
- Fetch every
Genesis.ApplicationwhereisPreprovisioned = falseand aGenesis.PreprovisionedApplicationentry references it with statusCLAIMED. - For each such application, resolve the on-disk folder name by inspecting
/usr/local/share/c3/fs/genesis/${pkgsPath}/inside its studio env and locating the single*.c3pkg.jsonfile. The folder name is the correctpkgName. merge()the resolved value onto the application to overwrite the driftedpkgName.- Applications created under new code have
pkgName = pkg<uuid-prefix>from create time and match disk, so backfill is only needed once.
Non-goals.
- This change does not attempt to rename the folder on disk, and does not require the pkg store to relocate the package.
- This change does not add a new field on
Genesis.Application. The singlepkgNamefield carries both identity and location; keeping them equal is enforced by construction (pkgNameis set at create and never rewritten). - The user-facing display name (
name) continues to change on claim as before — only the package identity is frozen.
Configuration
Default Pool Sizes (seed data)
| Template | Pool Size | Max Age |
|---|---|---|
| blankPkg | 3 | 48h |
| genaiPkg | 2 | 48h |
Workspace pools are configured dynamically per-application by platform admins.
Observability
Structured Logging
Every reconciliation cycle logs:
Pool status [Application]: template=blankPkg ready=2 provisioning=1 errored=0 desired=3
Pool status [Workspace]: application=abc123 ready=1 provisioning=0 errored=1 desired=2Key events logged:
- Pool deficit detection with counts
- Successful claims (template/app, user, new name, claimDurationMs)
- Failed claims (race condition fallback)
- Claim duration per entry
- Provisioning start/completion/failure
- Expiry and cleanup actions
Key Metrics to Monitor
| Metric | What it tells you |
|---|---|
| Claim hit rate | % of creations served from pool vs. fallback |
| Claim duration (claimDurationMs) | End-to-end time including cross-env admin sync |
| Pool fill level | READY / desired ratio per pool |
| Provisioning duration | Time from PROVISIONING to READY |
| Error rate | Failed provisioning attempts |
| Staleness | Average time a READY entry sits before claim or expiry |
Failure Modes and Mitigations
| Failure | Impact | Mitigation |
|---|---|---|
| Pool exhausted | Users fall back to normal creation (no degradation) | Alert on low fill rate, increase poolSize |
| Provisioning failure | Entry marked ERROR, cleaned up in 24h | Monitor error rate, fix underlying issues |
| Race condition on claim | One user retries up to 3 times, then falls back to normal creation | Optimistic locking via merge(); retry loop fetches next READY entry |
| Claim downstream failure | Entry marked ERROR with errorMessage, user falls back to normal creation | Monitor claim error rate; investigate errorMessage |
| Stale entries | User gets outdated server version or git state | maxAgeHours forces expiry and refresh |
| Reconciler down | Pool drains over time | CronJob auto-restarts; pool drains gracefully |
| addEnvAdmin fails in claim | Claim returns null, falls back to normal creation | User unaffected; investigate remote env health |
| Runaway provisioning | Resource exhaustion | Deficit calculation caps at poolSize |
| Thread starvation | provisionNew blocks threads, preventing configureWorkspace | Non-blocking provisionNew + reconciler-driven promotion |
| Stuck PROVISIONING entry | Entry never promoted, wastes pool capacity | 30-minute timeout in promoteReady marks as ERROR |
| UI pod stuck "Waiting for UI directory" on resume of a claimed prewarmed workspace | Preview never comes up; UI service polls a path that doesn't exist on disk | Keep pkgName stable by construction: provisionNew sets pkgName = pkg<uuid-prefix> (matches the folder created on disk) and transferOwnership no longer rewrites it — only the user-facing name changes on claim (see On-disk name stability) |
Resource Impact
Each preprovisioned entry consumes:
- 1 Kubernetes pod (3 CPU, 48GB RAM for SNE + agent/UI/code sidecars)
- 1 Azure Managed Disk (100 GiB XFS)
- 1 StudioEnv + StudioApp in the platform DB
Estimated steady-state cost (with default configs):
- Application pool: 5 pods (3 blankPkg + 2 genaiPkg)
- Workspace pool: Variable per enabled application
The expiry mechanism (48h for apps, 24h for workspaces) ensures resources are recycled regularly.
File Layout
repo/genesis/src/preprovisioning/
├── Genesis.Preprovisioning.c3typ # Controller utility type (reconcilePools)
├── Genesis.Preprovisioning.js # reconcilePools, cleanup
├── Genesis.PreprovisioningStatus.c3typ # Shared enum: PROVISIONING, READY, CLAIMING, CLAIMED, ERROR, EXPIRED
├── Genesis.PreprovisionedApplication.c3typ # Entity type
├── Genesis.PreprovisionedApplication.PoolConfig.c3typ
├── Genesis.PreprovisionedApplication.js # provisionNew (claim delegated to PrivilegedAction)
├── Genesis.PreprovisionedWorkspace.c3typ # Entity type
├── Genesis.PreprovisionedWorkspace.PoolConfig.c3typ
└── Genesis.PreprovisionedWorkspace.js # provisionNew (claim delegated to PrivilegedAction)
repo/genesis/src/
├── Genesis.PrivilegedAction.c3typ # claimWorkspace, claimApplication declarations
└── Genesis.PrivilegedAction.js # Claim logic: single privileged lambda with ownership transfer
repo/genesis/data/
├── Genesis.PreprovisionedApplication.PoolConfig/
│ ├── blankPkg.json
│ └── genaiPkg.json
└── Genesis.PreprovisionedWorkspace.PoolConfig/
└── example.json # Disabled template for referenceModified existing files:
repo/genesis/src/Genesis.Template.js- claim before createrepo/genesis/src/Genesis.PrivilegedAction.c3typ- declaresclaimWorkspaceandclaimApplicationwith@action(authz='always')repo/genesis/src/Genesis.PrivilegedAction.js- full claim implementation withexecutePrivilegedJsLambdarepo/genesis/src/Genesis.Application.c3typ-isPreprovisionedfieldrepo/genesis/src/Genesis.Application.js- quota bypassrepo/genesis/src/workspace/Genesis.Workspace.c3typ-isPreprovisionedfield +createWorkspacemethod +@dependencyonconfigureWorkspacerepo/genesis/src/workspace/Genesis.Workspace.js- quota bypass +createWorkspace+ Studio hibernation policy for preprovisionedrepo/genesis/src/preprovisioning/Genesis.PreprovisionedApplication.js-provisionNewsetspkgName = 'pkg' + <uuid-prefix>explicitly at create (instead of lettingbeforeCreatederive it from the placeholder display name);transferOwnershipno longer rewritespkgNameon claimrepo/genesis/metadata/Role/C3.Code.User.json-allow:Genesis.PrivilegedAction::claimWorkspaceandclaimApplicationaction permissions
Known Issues and Workarounds
Thread Starvation (Resolved)
Problem: The original provisionNew implementation held an async action thread for up to 20 minutes while calling workspace.waitForReady(). Since configureWorkspace (which makes the workspace READY) also needs an async action thread, this created a deadlock when the thread pool was saturated — provisionNew blocked waiting for READY, but READY could never be reached because configureWorkspace couldn't get a thread.
Resolution: provisionNew was refactored to be non-blocking. It creates the application/workspace, links it to the pool entry, and returns immediately. A new promoteReady() step in the reconciler checks PROVISIONING entries every minute and promotes them once their workspace reaches READY. This eliminates all blocking waits from the async action queue.
Reconciler Must Run as ClusterAdmin
Problem: When the CronJob was deployed via seed data, it ran as provisioner@c3 — a user without permissions to perform operations like OAuthApplication#fetch and C3FileSystem#setMount in provisioned environments. The worker user is also explicitly excluded from receiving C3.EnvAdmin by the platform's StudioEngine.upsertUserToApp() method.
Resolution: The reconciler CronJob is no longer deployed via seed data. Instead, a ClusterAdmin must call Genesis.Preprovisioning.enableReconciler() from the console after deployment. This creates both CronJobs with runAsCreatedBy: true, so all operations run with ClusterAdmin permissions.
Cross-Env addEnvAdmin Authorization (Resolved)
Problem: When a non-admin user (e.g., mytestuser with only C3.Code.User role) claims a preprovisioned application or workspace, the original claim code called ws.addEnvAdmin(user) directly. This makes a cross-env REST call (env.callJson('Lambda', ...)) to the remote StudioEnv to execute User#upsert and user.addToGroup('C3.EnvAdmin'). The call authenticates as the current session user — but that user doesn't exist there yet.
Resolution: The claim flow was moved into Genesis.PrivilegedAction.claimWorkspace / claimApplication with @action(authz='always'). All ownership-transfer writes (Genesis.Application, Genesis.Workspace, StudioEnv, StudioApp merges) run inside a single StudioCluster.executePrivilegedJsLambda call which executes in Ctx.asRoot(), bypassing all data-permission checks. The isPreprovisioned == true write data permission was removed from C3.Code.User. After the privileged lambda, doSyncWorkspaceEnvAdmins runs in the calling user's context (now an admin thanks to populateAcl calls inside the lambda) to register them in the remote environment.
Preprovisioned Workspaces Hibernating (Resolved)
Problem: Preprovisioned workspaces were being hibernated despite the hibernationEnabled = false logic in configureWorkspace. Two root causes:
configureWorkspaceaccessedthis.isPreprovisioneddirectly, but when invoked asynchronously viaAsyncAction, the field was never fetched from the DB — it was alwaysundefined, so theifblock was skipped andhibernationEnabledremained at its defaulttrue. Note:@dependency(include='isPreprovisioned')annotations do not reliably hydrate fields forAsyncAction-dispatched methods.Studio's platform-level
StudioCluster.Hibernationhas its own inactivity policy independent of Genesis. Even ifhibernationEnabledwere correctly set, Studio would still hibernate idle environments after its own timeout.
Resolution: Changed the field access to an explicit DB fetch via this.get('isPreprovisioned').isPreprovisioned, which guarantees the value is loaded regardless of how the method was invoked. Also added StudioCluster.Hibernation.setPolicyForSenvs([envId], 'InactivityNoPolicy') to disable Studio-level auto-hibernation for pool workspaces.
Future Enhancements
- Adaptive pool sizing: Use claim rate history to auto-tune poolSize per template.
- Priority queuing: When pool is empty and multiple users request simultaneously, queue them rather than all hitting the slow path.
- Warm workspace for non-git apps: Extend PreprovisionedWorkspace to template-based (non-git) applications.
- Pool sharing across clusters: For multi-region deployments, share pool status to route users to clusters with available entries.
- Hibernation-aware prewarming: When a preprovisioned workspace approaches maxAge, hibernate it rather than terminate (cheaper to resume than re-provision).
- Remove STOPPED workspaces from pool: Detect when a preprovisioned workspace's underlying environment enters STOPPED state and remove it from the PreprovisionedWorkspace and PreprovisionedApplication collections (mark as ERROR or EXPIRED), so that stale stopped entries are never claimed.