mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-03 15:43:59 +00:00
承接用户提问「设计上停用不是本来就会跨节点传输吗」——核实结论:结构上确实 如此(TopoEntry.Link 是完整 store.Link,整个 State 随 token 每轮广播),但 实际路径断了。断点正是「撤销会删掉 topology 条目」:条目是 flag 的载体, 删了就无处传播,于是停用只能靠一次性 revoke 任务投递给 owner,**owner 当时 不在线就收不到**(实测 .60 记 disabled=1 / .106 记 0,就是这么来的)。 ## 改为标记而非移除 撤销不再 RemoveTopology,而是 UpdateTopologyDisabled(true),条目保留、 Link.Disabled=true、Active=false。Active 正是为此存在:OfflineReassign() 只处理 Active 条目,所以停用的转发在 owner 掉线时不会被重新排队。 - 新增 UpdateTopologyDisabled / TopologyDisabled(照 UpdateTopologyGroup 的桥) - 新增 store.ReconcileLinkDisabled 作接收端:adoption 时把环上的 flag 落进 本地 store;本节点没有该转发时补一条 disabled 占位行(否则日后在本节点被 claim 会复活),enable 则不建行 - SetTopologySync 由单向(store→环)扩为双向:群组仍上行,disabled 下行 - AddTopology 的 Active 跟随 Link.Disabled(原本硬编码 true,认领一个停用 转发就会复活它) - 审计日志细分 forward.stop / forward.start,与 forward.remove 区分 ## 语义变更带出的两个新问题(都已修) 1. **「启动」这条路断了**。条目保留 ⇒ SubmitTask 被去重挡下,而认领路径的 duplicate-claim 防御又会丢弃「已有 owner」的任务 ⇒ 重启任务发不出去,owner 永远收不到,转发**能停不能起**。 修:新增 Task.Restart 这一独立任务类型 + SubmitRestart + Handler.RestartFn, 显式绕过 duplicate-claim 防御并原地复活(不重复建条目、不重跑 claim 簿记)。 SubmitTask 的守卫同时从 HasTask 收窄为新的 HasActiveTask(跳过 disabled 条目 与撤销任务);saveCanvas 的判断相应改用 HasActiveTask,避免每次保存都对 已标记的转发重复发撤销。 2. 原本两处 RemoveTopologyEntry 调用(ClaimFn/RevokeFn 的 disabled 分支)在 新语义下会把本该保留的条目删掉,改为 UpdateTopologyDisabled。 ## 测试(每个都做了「回退修复行→必须变红→还原变绿」双向验证) - TestStoppedTopologyEntrySurvivesAdoption —— 离线成员也能学到停用, 一次性 revoke 任务永远做不到这一点 - TestStoppedForwardNotRequeuedOnNodeDeparture / TestAddTopologyRespectsDisabledFlag —— 标记而非删除为何安全 - TestSubmitTaskNotBlockedByStoppedEntry / TestSubmitTaskStillDedupesActiveForward - TestRestartTaskBypassesDuplicateClaimGuard / TestRestartFlagSurvivesTokenSerialization - TestStopThenStartPublishesRestartTask(HTTP 端到端,断言**任务真的发出**) - TestReconcileLinkDisabled*(store 侧三条) ★ 两次踩到**假绿**:第一版只断言 store 层(newTestHandler 的 Ring 为 nil, 坏掉的路根本没执行);第二版在 re-enable **之后**才调 SubmitTask,此时新旧 谓词结果相同,测不出差异。都是靠「回退修复行看是否变红」抓出来的 —— 这个 双向验证已经是本项目的固定动作。 go build / go vet / go test ./... 全绿,gofmt 干净。
289 lines
9.0 KiB
Go
289 lines
9.0 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// seed inserts the local + remote rows a link's foreign keys require.
|
|
// (links.local / links.remote reference their own tables, so a link cannot
|
|
// exist on its own — the same reason ClaimFn upserts them before ReplaceLinks.)
|
|
func seed(t *testing.T, st *Store, locals []string, remote string) {
|
|
t.Helper()
|
|
for _, n := range locals {
|
|
if err := st.UpsertLocal(Local{Name: n, IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := st.UpsertRemote(Remote{Name: remote, IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestLinkByTripleSurvivesReplaceLinks pins the reason LinkByTriple exists.
|
|
//
|
|
// ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so sqlite
|
|
// hands every row a FRESH autoincrement id. A Link captured before such a
|
|
// write (e.g. one riding inside a ring token) therefore carries an id that
|
|
// either matches a different forward or matches nothing. The natural key
|
|
// (local, remote, remotePort) is what every caller actually identifies a
|
|
// forward by, and it must survive those rewrites.
|
|
func TestLinkByTripleSurvivesReplaceLinks(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
|
|
links := []Link{
|
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
|
{Local: "gamma", Remote: "srv", RemotePort: 300},
|
|
}
|
|
if err := st.ReplaceLinks(links); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Capture the ids as the ring would have them.
|
|
before := map[string]int64{}
|
|
all, err := st.ListLinks()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, l := range all {
|
|
before[l.Local] = l.ID
|
|
}
|
|
if len(before) != 3 {
|
|
t.Fatalf("expected 3 links, got %d", len(before))
|
|
}
|
|
|
|
// Rewrite the table (this is what saveCanvas and ClaimFn both do).
|
|
if err := st.ReplaceLinks(links); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := st.ListLinks()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(after) != 3 {
|
|
t.Fatalf("expected 3 links after rewrite, got %d", len(after))
|
|
}
|
|
|
|
// The natural key must still resolve to the right forward, with its
|
|
// disabled flag and group intact.
|
|
for _, l := range after {
|
|
if l.Disabled {
|
|
t.Errorf("link %s unexpectedly disabled after a plain rewrite", l.Local)
|
|
}
|
|
}
|
|
got, found, err := st.LinkByTriple("beta", "srv", 200)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !found {
|
|
t.Fatal("LinkByTriple failed to find beta after ReplaceLinks")
|
|
}
|
|
if got.Local != "beta" || got.RemotePort != 200 {
|
|
t.Fatalf("LinkByTriple returned the wrong row: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestLinkByTripleNotFoundIsNotError documents the contract callers rely on:
|
|
// "no persisted opinion yet" is (Link{}, false, nil), not an error. A fresh
|
|
// claim of a link with no row must be allowed to start.
|
|
func TestLinkByTripleNotFoundIsNotError(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
got, found, err := st.LinkByTriple("nope", "srv", 1234)
|
|
if err != nil {
|
|
t.Fatalf("missing link must not be an error, got %v", err)
|
|
}
|
|
if found {
|
|
t.Fatalf("missing link reported as found: %+v", got)
|
|
}
|
|
if got.Local != "" || got.RemotePort != 0 {
|
|
t.Fatalf("expected zero Link on miss, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestLinkByTripleReadsDisabledFlag is the store-level half of the
|
|
// "stopped forwards resurrect on restart" bug: the claim path asks the store
|
|
// whether the user disabled this forward, so this lookup must return the flag
|
|
// as persisted.
|
|
func TestLinkByTripleReadsDisabledFlag(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
seed(t, st, []string{"mc"}, "srv")
|
|
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetLinkDisabled("mc", "srv", 25565, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !found {
|
|
t.Fatal("expected to find the link")
|
|
}
|
|
if !ln.Disabled {
|
|
t.Fatal("expected Disabled=true to be visible through LinkByTriple")
|
|
}
|
|
if ln.Group != "game" {
|
|
t.Fatalf("group should survive, got %q", ln.Group)
|
|
}
|
|
|
|
// ...and the user-facing start path must be able to clear it again.
|
|
if err := st.SetLinkDisabled("mc", "srv", 25565, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
|
|
t.Fatal("expected Disabled=false after clearing")
|
|
}
|
|
}
|
|
|
|
// TestGetLinkByIDIsStaleAfterReplaceLinks documents WHY callers must not use
|
|
// GetLink(id) with a previously captured id. It is not a fix — it is the trap
|
|
// being pinned shut, so the hazard stays visible if someone reintroduces it.
|
|
func TestGetLinkByIDIsStaleAfterReplaceLinks(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
|
|
if err := st.ReplaceLinks([]Link{
|
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
all, _ := st.ListLinks()
|
|
var staleID int64
|
|
for _, l := range all {
|
|
if l.Local == "alpha" {
|
|
staleID = l.ID
|
|
}
|
|
}
|
|
|
|
if err := st.ReplaceLinks([]Link{
|
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
|
{Local: "gamma", Remote: "srv", RemotePort: 300},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The old id may still resolve, but to whatever row now occupies that
|
|
// id — which is exactly the silent-mis-target hazard. Assert that the
|
|
// natural key remains the only safe handle.
|
|
if ln, ok := st.GetLink(staleID); ok && ln.Local != "alpha" {
|
|
t.Logf("stale id %d now points at %q (hazard confirmed; use LinkByTriple)", staleID, ln.Local)
|
|
}
|
|
if got, found, _ := st.LinkByTriple("alpha", "srv", 100); !found || got.Local != "alpha" {
|
|
t.Fatalf("natural key must stay reliable, got %+v found=%v", got, found)
|
|
}
|
|
}
|
|
|
|
// TestReconcileLinkDisabledLearnsPeerDecision is the store half of
|
|
// cluster-wide stop propagation. A node that did NOT serve the stop request has
|
|
// no reason to know about it, and its links table is node-local — so the flag
|
|
// arrives via the ring and lands here. The case that matters is the OWNER of a
|
|
// forward on a different machine: before this existed, that node's copy still
|
|
// read "enabled", so it kept (or re-spawned) the worker for a forward the user
|
|
// had explicitly stopped.
|
|
func TestReconcileLinkDisabledLearnsPeerDecision(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
seed(t, st, []string{"mc"}, "srv")
|
|
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A peer's stop arrives.
|
|
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !found {
|
|
t.Fatal("link disappeared during reconcile")
|
|
}
|
|
if !ln.Disabled {
|
|
t.Fatal("the peer's stop did not land in the local store")
|
|
}
|
|
if ln.Group != "game" {
|
|
t.Fatalf("reconcile must not clobber other fields, group=%q", ln.Group)
|
|
}
|
|
|
|
// A peer's re-enable arrives.
|
|
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
|
|
t.Fatal("the peer's re-enable did not land")
|
|
}
|
|
}
|
|
|
|
// TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward: a node that has
|
|
// never seen the forward still must remember that it is stopped, otherwise a
|
|
// later claim on that node would resurrect it.
|
|
func TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
seed(t, st, []string{"ghost"}, "srv")
|
|
|
|
if err := st.ReconcileLinkDisabled("ghost", "srv", 9999, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, found, err := st.LinkByTriple("ghost", "srv", 9999)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !found {
|
|
t.Fatal("a stopped-but-unknown forward must be remembered, or a later claim resurrects it")
|
|
}
|
|
if !ln.Disabled {
|
|
t.Fatal("placeholder is not marked disabled")
|
|
}
|
|
}
|
|
|
|
// TestReconcileLinkDisabledIgnoresEnableForUnknown: an enable for a forward this
|
|
// node has never seen must NOT create a row. Creating one would invent forwards
|
|
// out of ring state.
|
|
func TestReconcileLinkDisabledIgnoresEnableForUnknown(t *testing.T) {
|
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
seed(t, st, []string{"other"}, "srv")
|
|
|
|
if err := st.ReconcileLinkDisabled("unknown", "srv", 1234, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, found, _ := st.LinkByTriple("unknown", "srv", 1234); found {
|
|
t.Fatal("an enable for an unknown forward must not materialise a row")
|
|
}
|
|
}
|