feat(panel): coalesce binding-class rebuilds in a fixed 10s window (item 23)

Assign/unassign, customer-form alignment, suspend/resume and
credential rotation no longer rebuild (and restart the node's
sing-box) per change. They mark the node rebuild-pending; a 1s
background flusher applies exactly one rebuild with the final state
when the FIXED window — 10s from the first pending change, never
extended by later changes — elapses. Manual deploy, repair, node
edits and server reconciles stay immediate and consume the mark.
Removal clears it, and the flusher discards marks on deleted /
removing / draft nodes, so a pending apply can never resurrect a
node. The mark is persisted in the data file, so a panel restart
inside the window cannot strand a node: the first post-boot pass
flushes whatever is due.
This commit is contained in:
aaaamoooo
2026-10-21 10:43:27 +00:00
parent 2fafd85d1f
commit 80140f391f
5 changed files with 307 additions and 18 deletions
+84 -15
View File
@@ -388,6 +388,75 @@ func DeriveNodeStatus(node *Node) {
node.Error = nil
}
// ---------- coalesced rebuilds (round-3 item 23) ----------
// RebuildCoalesceWindow is the FIXED coalescing window for binding-class
// rebuilds: it starts at a node's first pending change and is never
// extended by later changes inside it, so a burst of assign/unassign
// operations can never postpone the dispatch indefinitely. When the
// window elapses the flusher applies exactly one rebuild with the
// node's final state.
const RebuildCoalesceWindow = 10 * time.Second
// markRebuildPending records a binding-class change for the coalesced
// flusher instead of rebuilding (and restarting the node's sing-box)
// immediately. The window is anchored at the first pending change:
// re-marking inside the window only widens the exit flag.
func markRebuildPending(node *Node, includeExit bool) {
if !node.RebuildPending {
node.RebuildPending = true
node.RebuildPendingAt = NowISO()
}
if includeExit {
node.RebuildPendingExit = true
}
node.UpdatedAt = NowISO()
}
// clearRebuildPending drops a pending coalesced rebuild — used when an
// immediate action (manual deploy, repair, node edit, removal, server
// reconcile) already ships the node's current state or ends its life.
func clearRebuildPending(node *Node) {
node.RebuildPending = false
node.RebuildPendingAt = ""
node.RebuildPendingExit = false
}
// FlushPendingRebuilds applies one rebuild per node whose fixed window
// has elapsed, with the node's state as of now. Pending marks on nodes
// that were deleted, are being removed, or fell back to draft are
// discarded — a pending apply must never resurrect a node. Called once
// per second from the panel's background loop; because the mark is
// persisted in the data file, a panel restart inside the window simply
// means the first post-boot pass flushes whatever is due.
func (o *Orchestrator) FlushPendingRebuilds(now time.Time) (int, error) {
flushed := 0
err := o.tx(func(d *Data) error {
for _, node := range d.Nodes {
if !node.RebuildPending {
continue
}
at, ok := ParseISO(node.RebuildPendingAt)
if !ok || now.Sub(at) < RebuildCoalesceWindow {
continue
}
includeExit := node.RebuildPendingExit
clearRebuildPending(node)
if node.PendingDelete || node.Status == "removing" || node.Status == "draft" {
continue
}
if err := RebuildNode(d, node, includeExit); err != nil {
node.Error = StrPtr(err.Error())
Audit(d, "system", "rebuild_flush_failed", node.ID, map[string]any{"error": err.Error()})
continue
}
flushed++
}
return nil
})
return flushed, err
}
// ---------- customer suspend / resume ----------
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
@@ -409,9 +478,7 @@ func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, e
for _, nodeID := range affected {
node := orchFindNode(d, nodeID)
if node != nil && node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return len(affected), err
}
markRebuildPending(node, false)
}
}
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
@@ -462,9 +529,7 @@ func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, er
}
}
includeExit := forwardNode(node) && !node.ExitReady
if err := RebuildNode(d, node, includeExit); err != nil {
return len(affected), err
}
markRebuildPending(node, includeExit)
}
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
return len(affected), nil
@@ -815,6 +880,9 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
}
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
}
// The reconcile just shipped the node's current state; a
// coalesced pending mark for it is satisfied.
clearRebuildPending(node)
node.Status = "deploying"
}
node.UpdatedAt = NowISO()
@@ -898,6 +966,9 @@ func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
if err := ValidateNodeDeploy(d, node); err != nil {
return err
}
// Manual deploy is immediate (item 23 constraint 1): it ships the
// current state now and consumes any coalesced pending mark.
clearRebuildPending(node)
if err := RebuildNode(d, node, true); err != nil {
return err
}
@@ -919,6 +990,9 @@ func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool
}
node.PendingDelete = deleteRecord
node.AutoRemoving = BoolPtr(false)
// A pending coalesced apply must never resurrect a node that is
// being removed (item 23 constraint 2).
clearRebuildPending(node)
if node.Status != "draft" {
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
if forwardNode(node) && node.ExitResourceID != "" {
@@ -966,6 +1040,7 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
if node.Status != "failed" && node.Status != "degraded" {
return httpErr(409, "节点状态正常,无需修复")
}
clearRebuildPending(node)
if err := RebuildNode(d, node, true); err != nil {
return err
}
@@ -1022,9 +1097,7 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
markRebuildPending(node, false)
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
return nc, nil
@@ -1047,9 +1120,7 @@ func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
markRebuildPending(node, false)
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
@@ -1130,9 +1201,7 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
markRebuildPending(node, false)
}
return nil
}