Skip to content

Commit 89294bc

Browse files
author
Chris Li
committed
fix: review follow-ups — idempotency preview guard + TTL cache + empty-array metadata
Addresses the three deferred review comments from #663: 1. Idempotency no longer dedupes SIMULATED contractWrite previews. A preview (is_simulated unset/true) skips the idempotency cache entirely, so a client reusing a key across a preview and a later real execute can't get the cached preview back in place of the broadcast. Only an explicit is_simulated=false contractWrite is deduped (contractWriteIsSimulated mirrors the runner default). 2. The idem:noderun: cache now uses a native Badger TTL (new Storage.SetWithTTL, backed by badger.Entry.WithTTL) so entries are reaped by the store even when a key is never re-read (the common fresh-key-per-action case) — bounding growth instead of relying on read-after-expiry deletion. The embedded timestamp + read-time check remain as a safety net. 3. runNodeRespToOpenAPI wraps an EMPTY results array as {results: []} instead of dropping it to metadata=null, so clients see a consistent shape for a legitimately empty result set. Also: gofmt trailing newline in version/version.go (left by the release bump). Tests: contractWriteIsSimulated resolution, empty-array wrapping. Build clean.
1 parent c151221 commit 89294bc

6 files changed

Lines changed: 83 additions & 7 deletions

File tree

aggregator/rest/handlers_nodes.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ func runNodeRespToOpenAPI(in *avsproto.RunNodeWithInputsResp) generated.RunNodeR
137137
// results array as metadata. RunNodeResponse.metadata is an object,
138138
// so a bare array was previously dropped here — taking the per-method
139139
// receipts (executionStatus, userOpHash, transactionHash) with it.
140-
// Wrap it under "results" so those actually reach the client.
141-
if len(v) > 0 {
142-
wrapped := map[string]interface{}{"results": v}
143-
out.Metadata = &wrapped
140+
// Wrap it under "results" so those reach the client — including an
141+
// EMPTY array (results: []), so callers always see a consistent shape
142+
// rather than metadata=null for a legitimately empty result set.
143+
if v == nil {
144+
v = []interface{}{}
144145
}
146+
wrapped := map[string]interface{}{"results": v}
147+
out.Metadata = &wrapped
145148
}
146149
}
147150
if ec := in.GetExecutionContext(); ec != nil {

aggregator/rest/handlers_nodes_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ func TestRunNodeRespToOpenAPISurfacesContractWriteReceiptArray(t *testing.T) {
4242
assert.Equal(t, "0xtx", receipt["transactionHash"])
4343
}
4444

45+
// An EMPTY results array must still surface as {results: []}, not metadata=null,
46+
// so clients see a consistent shape for a legitimately empty result set.
47+
func TestRunNodeRespToOpenAPIEmptyArrayMetadataWrapped(t *testing.T) {
48+
md, err := structpb.NewValue([]interface{}{})
49+
require.NoError(t, err)
50+
51+
out := runNodeRespToOpenAPI(&avsproto.RunNodeWithInputsResp{Success: true, Metadata: md})
52+
53+
require.NotNil(t, out.Metadata, "empty array metadata must still be surfaced")
54+
m := *out.Metadata
55+
arr, ok := m["results"].([]interface{})
56+
require.True(t, ok, "empty array must be wrapped under 'results'")
57+
assert.Empty(t, arr)
58+
}
59+
4560
// Map-typed metadata (e.g. ethTransfer's gasUsed/transactionHash) must keep
4661
// surfacing as-is, without the "results" wrapper.
4762
func TestRunNodeRespToOpenAPIMapMetadataStillForwarded(t *testing.T) {

core/taskengine/run_node_immediately.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3037,13 +3037,35 @@ func idempotencyCacheKey(subject, clientKey string) []byte {
30373037
// a still-pending UserOp) is cached; a transport/engine error caches nothing so
30383038
// the caller may safely retry. Polling a pending UserOp's final status is a
30393039
// separate concern — reuse of the same key returns the cached pending result.
3040+
// contractWriteIsSimulated mirrors the runner's is_simulated resolution for a
3041+
// contractWrite node: the default is SIMULATION unless is_simulated is explicitly
3042+
// set to false. Only contractWrite carries this flag and is the node that
3043+
// broadcasts a real UserOp on this path. Returns false for any other node type so
3044+
// their idempotency behavior is unchanged.
3045+
func contractWriteIsSimulated(node *avsproto.TaskNode) bool {
3046+
cw := node.GetContractWrite()
3047+
if cw == nil {
3048+
return false
3049+
}
3050+
cfg := cw.GetConfig()
3051+
if cfg == nil || cfg.IsSimulated == nil {
3052+
return true // unset => simulation (matches the runner default)
3053+
}
3054+
return cfg.GetIsSimulated()
3055+
}
3056+
30403057
func (n *Engine) RunNodeImmediatelyRPCIdempotent(ctx context.Context, user *model.User, req *avsproto.RunNodeWithInputsReq, idempotencyKey string) (*avsproto.RunNodeWithInputsResp, error) {
30413058
// Idempotency needs a distinct authenticated subject to scope the cache key.
30423059
// A partner-assertion (simulate-only) caller can resolve to the zero address;
30433060
// deduping those would let one caller's key cross-replay another's result, so
30443061
// skip idempotency when there is no real subject. Safe by construction: real
30453062
// executes (fund-moving) always carry a non-zero authenticated owner.
3046-
if idempotencyKey == "" || user == nil || user.Address == (common.Address{}) {
3063+
//
3064+
// Also skip for a SIMULATED contractWrite: idempotency exists to prevent a
3065+
// second broadcast of a real UserOp, so a preview must never be cached — else a
3066+
// client that reused a key across a preview and a later real execute could get
3067+
// the cached preview back in place of the broadcast.
3068+
if idempotencyKey == "" || user == nil || user.Address == (common.Address{}) || contractWriteIsSimulated(req.GetNode()) {
30473069
return n.RunNodeImmediatelyRPCWithContext(ctx, user, req)
30483070
}
30493071
cacheKey := idempotencyCacheKey(user.Address.Hex(), idempotencyKey)
@@ -3118,7 +3140,10 @@ func (n *Engine) writeIdempotentResponse(cacheKey []byte, resp *avsproto.RunNode
31183140
buf := make([]byte, 8+len(payload))
31193141
binary.BigEndian.PutUint64(buf[:8], uint64(time.Now().UnixNano()))
31203142
copy(buf[8:], payload)
3121-
if err := n.db.Set(cacheKey, buf); err != nil && n.logger != nil {
3143+
// Persist with a native store TTL so the entry is reaped by the DB even if it is
3144+
// never read again (the common case — a fresh key per action). The embedded
3145+
// timestamp + read-time expiry check remain as a belt-and-suspenders safety net.
3146+
if err := n.db.SetWithTTL(cacheKey, buf, idempotencyTTL); err != nil && n.logger != nil {
31223147
n.logger.Warn("RunNodeImmediately: failed to persist idempotent response", "error", err)
31233148
}
31243149
}

core/taskengine/run_node_immediately_idempotency_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ import (
1616
"google.golang.org/protobuf/proto"
1717
)
1818

19+
// TestContractWriteIsSimulated locks the guard that keeps idempotency off simulated
20+
// contractWrite previews: default (unset) is simulation, only an explicit
21+
// is_simulated=false is a real execute, and non-contractWrite nodes are unaffected.
22+
func TestContractWriteIsSimulated(t *testing.T) {
23+
cwNode := func(sim *bool) *avsproto.TaskNode {
24+
return &avsproto.TaskNode{TaskType: &avsproto.TaskNode_ContractWrite{
25+
ContractWrite: &avsproto.ContractWriteNode{
26+
Config: &avsproto.ContractWriteNode_Config{IsSimulated: sim},
27+
},
28+
}}
29+
}
30+
tr, fa := true, false
31+
32+
require.True(t, contractWriteIsSimulated(cwNode(nil)), "unset => simulation (runner default)")
33+
require.True(t, contractWriteIsSimulated(cwNode(&tr)), "explicit true => simulated")
34+
require.False(t, contractWriteIsSimulated(cwNode(&fa)), "explicit false => real execute")
35+
require.False(t, contractWriteIsSimulated(nil), "nil node => not simulated (unchanged)")
36+
require.False(t, contractWriteIsSimulated(&avsproto.TaskNode{
37+
TaskType: &avsproto.TaskNode_CustomCode{CustomCode: &avsproto.CustomCodeNode{}},
38+
}), "non-contractWrite => idempotency behavior unchanged")
39+
}
40+
1941
// customCodeReq builds a single-node RunNodeWithInputs request whose customCode
2042
// node returns the given constant, so distinct sources produce distinct results.
2143
func customCodeReq(source string) *avsproto.RunNodeWithInputsReq {

storage/db.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
badger "github.com/dgraph-io/badger/v4"
1213
)
@@ -56,6 +57,10 @@ type Storage interface {
5657
BatchWrite(updates map[string][]byte) error
5758
Move(src, dest []byte) error
5859
Set(key, value []byte) error
60+
// SetWithTTL writes a key that the store auto-expires after `ttl`, so short-lived
61+
// entries (e.g. idempotency replay cache) are reaped by the DB itself rather than
62+
// only on read-after-expiry — bounding growth for keys that are never re-read.
63+
SetWithTTL(key, value []byte, ttl time.Duration) error
5964
Delete(key []byte) error
6065

6166
GetCounter(key []byte, defaultValue ...uint64) (uint64, error)
@@ -140,6 +145,12 @@ func (s *BadgerStorage) Set(key, value []byte) error {
140145
})
141146
}
142147

148+
func (s *BadgerStorage) SetWithTTL(key, value []byte, ttl time.Duration) error {
149+
return s.db.Update(func(txn *badger.Txn) error {
150+
return txn.SetEntry(badger.NewEntry(key, value).WithTTL(ttl))
151+
})
152+
}
153+
143154
func (s *BadgerStorage) Delete(key []byte) error {
144155
return s.db.Update(func(txn *badger.Txn) error {
145156
err := txn.Delete(key)

version/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ func Get() string {
2222
// GetRevision returns the revision
2323
func GetRevision() string {
2424
return revision
25-
}
25+
}

0 commit comments

Comments
 (0)