Skip to content

feat(paymodels): add auto-select-single-paymodel to survive workspace terminate - #168

Open
jawadqur wants to merge 1 commit into
masterfrom
feat/auto-select-single-paymodel
Open

jawadqur wants to merge 1 commit into
masterfrom
feat/auto-select-single-paymodel

Conversation

@jawadqur

Copy link
Copy Markdown
Contributor

Users on vpodc.data-commons.org are currently unable to launch workspaces after terminating one. This adds an opt-in fix.

The bug

Terminating a workspace fires this goroutine (hatchery.go:629):

go func() {
    for { ... poll until status == "Not Found" ... }
    err = resetCurrentPaymodel(userName)
}()

resetCurrentPaymodelInDB sets current_pay_model = false on every row for that user. On the next launch, getCurrentPayModel hits this branch (paymodels.go:108):

if pm == nil || len(*pm) == 0 {                    // nothing with current=true
    activePayModels, _ := payModelsFromDatabase(userName, false)
    if activePayModels != nil && len(*activePayModels) > 0 {
        return nil, nil                            // <-- here
    }
    pm, err := getDefaultPayModel()                // unreachable in this case
    return pm, nil
}

The row is still request_status: "active", so it returns nil, and hatchery.go:516 responds 500 Current Paymodel is not set. Launch forbidden. The default-pay-model fallback is unreachable — it only fires for users with zero rows.

Observed in vadcprod over two days across three users:

2026/09/23 21:39:18 Current Paymodel is not set. Launch forbidden for user craigrbarnes@uchicago.edu
2026/09/23 21:50:27 Current Paymodel is not set. Launch forbidden for user shawnoconnor@uchicago.edu
2026/09/24 15:11:37 Current Paymodel is not set. Launch forbidden for user aartiv@uchicago.edu

GET /paymodels for an affected user:

{
  "current_pay_model": null,
  "all_pay_models": [
    { "bmh_workspace_id": "3813c208-...", "request_status": "active",
      "current_pay_model": false, "hard-limit": 5, "soft-limit": 4 }
  ]
}

The intended recovery is re-selecting via POST /setpaymodel, but on a commons using default-pay-model as a blanket trial that choice is never surfaced, so users are simply stuck.

The fix

New opt-in key "auto-select-single-paymodel". When set, a user whose only active pay model is not flagged current gets that row returned as current.

Returning the user’s own row — rather than substituting DefaultPayModel — is deliberate. DefaultPayModel has no bmh_workspace_id, so pods would be annotated with an empty paymodel_type; handlePodDeleted would then pass podPaymodelID == "" and skip updatePayModelCost entirely, silently breaking cost tracking. Keeping the real row preserves the workspace ID, its hard-limit/soft-limit, and accrued total-usage.

Users with 2+ pay models are untouched: they have a real choice and the flag must not make it for them.

Tests

Three cases added to Test_GetCurrentPayModel, all passing; the 5 existing cases still pass unchanged.

case flag rows expected
AutoSelectSingle_SingleActiveNotCurrent on 1 returns the row
AutoSelectSingle_MultipleActiveNotCurrent on 2 nil (unchanged)
AutoSelectSingleDisabled_SingleActiveNotCurrent off 1 nil (unchanged)

Verified the new tests fail without the paymodels.go change and pass with it. go build ./..., go vet, and gofmt are clean.

Note: TestCreateLogGroup fails on this branch, but it also fails on clean master — pre-existing and unrelated.

Rollout

Default is off; no commons changes behavior until it opts in. For vpodc, add to the hatchery JSON in gen3-gitops:

"auto-select-single-paymodel": true,

Existing stuck rows self-heal on the next launch — no DynamoDB surgery needed.

Not addressed here

… terminate

Terminating a workspace runs resetCurrentPaymodel in a goroutine, which
sets current_pay_model=false on *every* row for that user. On commons
where users are never asked to pick a pay model, that leaves them unable
to launch again: getCurrentPayModel finds no row with current=true, sees
that active rows do exist, and returns nil, so /launch responds 500 with
"Current Paymodel is not set. Launch forbidden".

The default-pay-model fallback immediately below is unreachable in this
case -- it only fires for users with zero rows in the table.

Add an opt-in "auto-select-single-paymodel" config key. When set, a user
whose only active pay model is not flagged current has that row returned
as their current one. The row is returned as-is rather than substituting
DefaultPayModel so it keeps its bmh_workspace_id: pods are annotated with
the right paymodel_type and updatePayModelCost can still find the row to
bill, and the row's own hard/soft limits and accrued total-usage apply.

Users with more than one pay model are untouched -- they have a real
choice to make and the flag must not make it for them. Default is off,
so behaviour is unchanged for every commons that does not opt in.
@jawadqur jawadqur closed this Sep 24, 2026
@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed SUBTOTAL
tests/test_discoverypage.py 1 1
tests/test_workspace.py 1 1
TOTAL 2 2

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed SUBTOTAL
tests/test_discoverypage.py 1 1
tests/test_workspace.py 1 1
TOTAL 2 2

Please find the detailed integration test report here

Please find the Github Action logs here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants