fix: Derive the rename unwind deadline at its use site
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 5m58s

This commit is contained in:
2026-08-12 11:15:29 +00:00
parent cdc50b7aaf
commit 449684ceaa
3 changed files with 58 additions and 26 deletions
+15 -5
View File
@@ -585,8 +585,12 @@ func renameInstance(c *gin.Context) {
// walking away, and an unwind sharing that context fails with it — leaving
// the control plane renamed and admin's row not, which is the exact
// divergence this handler is arranged to prevent.
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
// and then acting lets two parallel PUTs both pass the check and then
@@ -643,7 +647,9 @@ func renameInstance(c *gin.Context) {
if claimed.RenamedAt != nil {
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
}
if _, err := db.Admin("admin_instances").UpdateOne(bgCtx,
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
}
@@ -677,9 +683,11 @@ func renameInstance(c *gin.Context) {
if err != nil {
// The control plane's own previous values, not admin's copy: admin's may
// be stale, and its slug is omitempty.
if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
releaseClaim("a failed record write")
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
@@ -695,9 +703,11 @@ func renameInstance(c *gin.Context) {
}
s := auth.Current(c)
audit.Write(bgCtx, models.AuditEntry{
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{
"instance_id": inst.InstanceID,
+12 -4
View File
@@ -666,8 +666,12 @@ func staffRenameInstance(c *gin.Context) {
// The unwind and the audit write must survive the request being cancelled:
// an unwind on a dead context leaves the two databases disagreeing, which is
// the failure the unwind exists for.
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
set := bson.M{"name": name}
slug := inst.Slug
@@ -704,17 +708,21 @@ func staffRenameInstance(c *gin.Context) {
}
if err != nil {
if cloud {
if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(bgCtx, models.AuditEntry{
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
@@ -38,6 +38,12 @@ export default function StaffInstancePage() {
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
// A cloud placeholder has no control-plane row yet, so there is no host to
// move and nothing to rename — the panel's wording and its control are both
// read from this one answer rather than from the deployment alone, which is
// how they came to contradict each other.
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
return (
<div className="grid gap-8">
@@ -86,23 +92,31 @@ export default function StaffInstancePage() {
* fixing a name on someone's behalf must not spend their next 24
* hours.
*/}
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
{/*
* Keyed on the instance so a success note cannot follow staff
* from one instance page to the next — the element stays
* mounted across that navigation.
*/}
<RenamePanel
key={data.instance.instance_id}
movesHost={data.instance.deployment === "cloud" && !data.instance.placeholder}
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
{cloudPlaceholder ? (
// The API refuses this with a 409, so offering the control
// would only be a form that cannot succeed.
<p className="text-[0.85rem] text-ink-3">
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
</p>
) : (
/*
* Keyed on the instance so a success note cannot follow staff
* from one instance page to the next — the element stays
* mounted across that navigation.
*/
<RenamePanel
key={data.instance.instance_id}
movesHost={movesHost}
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
)}
</Panel>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />