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:
+84
-15
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user