diff --git a/workflow/internal/checkpoint/checkpoint_json.go b/workflow/internal/checkpoint/checkpoint_json.go index e9430219..7f21a96e 100644 --- a/workflow/internal/checkpoint/checkpoint_json.go +++ b/workflow/internal/checkpoint/checkpoint_json.go @@ -16,8 +16,16 @@ type scopeKeyHasherImpl struct{} var scopeKeyHasherInstance hashmap.Hasher[workflow.ScopeKey] = scopeKeyHasherImpl{} +// scopeKeyHashSeed is a fixed process-wide seed. maphash.Hash's zero value +// picks a new random seed on first use, so without SetSeed the same key would +// hash differently on every call — breaking Load/Delete and shared-scope key +// collapse on a map restored from JSON. state.go seeds its ScopeKey hasher the +// same way. +var scopeKeyHashSeed = maphash.MakeSeed() + func (scopeKeyHasherImpl) Hash(s workflow.ScopeKey) uint64 { var h maphash.Hash + h.SetSeed(scopeKeyHashSeed) s.Hash(&h) return h.Sum64() } diff --git a/workflow/internal/checkpoint/json_test.go b/workflow/internal/checkpoint/json_test.go index cac7682a..ebb1ae25 100644 --- a/workflow/internal/checkpoint/json_test.go +++ b/workflow/internal/checkpoint/json_test.go @@ -4,6 +4,7 @@ package checkpoint_test import ( "encoding/json" + "fmt" "reflect" "testing" @@ -115,3 +116,28 @@ func TestRunnerStateData_JsonRoundtrip(t *testing.T) { t.Fatalf("ResponsePortOwners = %+v, want %+v", got.ResponsePortOwners, state.ResponsePortOwners) } } + +// A Checkpoint's StateData map is rebuilt from JSON on Unmarshal. Its scope-key +// hasher must use a fixed seed: a zero-value maphash.Hash picks a new random +// seed on every call, so Load on a restored map would miss the key it just +// stored (and shared-scope keys would not collapse). +func TestCheckpoint_JsonRoundtrip_StateDataRemainsLoadable(t *testing.T) { + key := workflow.ScopeKey{ID: workflow.ScopeID{ExecutorID: "exec1"}, Key: "k"} + keyJSON, err := json.Marshal(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + valJSON, err := json.Marshal(workflow.AnyPortableValue("v")) + if err != nil { + t.Fatalf("marshal value: %v", err) + } + data := []byte(fmt.Sprintf(`{"StateData":[{"Key":%s,"Value":%s}]}`, keyJSON, valJSON)) + + var cp checkpoint.Checkpoint + if err := json.Unmarshal(data, &cp); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if _, ok := cp.StateData.Load(key); !ok { + t.Fatal("restored StateData.Load(key) = false: the scope-key hasher is not deterministic across calls") + } +}