Skip to content

Commit 9f855b1

Browse files
committed
Issue 2063 silent storage error fix
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 5a779cd commit 9f855b1

2 files changed

Lines changed: 122 additions & 49 deletions

File tree

token/services/identity/role/registry.go

Lines changed: 97 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,51 @@ type WalletFactory interface {
2222
NewWallet(ctx context.Context, id idriver.WalletID, role idriver.IdentityRoleType, is IdentitySupport, info idriver.IdentityInfo) (driver.Wallet, error)
2323
}
2424

25+
// WalletIDStatus classifies the outcome of resolving an identity to the wallet id
26+
// bound to it. It exists so callers branch on an explicit, named state instead of
27+
// re-deriving intent from the ambiguous "(string, error)" shape — where ("", nil),
28+
// ("", err) and ("id", nil) each mean something different and the difference is easy
29+
// to get wrong (see issue #2063).
30+
type WalletIDStatus int
31+
32+
const (
33+
// WalletIDUnknown is the zero value and never returned by GetWalletID; it guards
34+
// against a WalletIDResolution that was constructed without going through GetWalletID.
35+
WalletIDUnknown WalletIDStatus = iota
36+
// WalletIDBound means storage holds a wallet id for the identity. WalletID is set.
37+
WalletIDBound
38+
// WalletIDUnbound means storage answered authoritatively that the identity has no
39+
// wallet binding. This is a definitive, successful miss: it is safe to fall through
40+
// to the next resolution step and, ultimately, to create a wallet.
41+
WalletIDUnbound
42+
// WalletIDFailed means the storage lookup itself failed (timeout, connection reset,
43+
// ...), so whether a binding exists is UNKNOWN. Err carries the cause. Callers MUST
44+
// NOT treat this as WalletIDUnbound: doing so lets a transient blip masquerade as an
45+
// unregistered identity and triggers the creation of a duplicate wallet.
46+
WalletIDFailed
47+
)
48+
49+
// WalletIDResolution is the explicit result of resolving an identity to its bound
50+
// wallet id. It is the single shared value every wallet-lookup fallback branches on,
51+
// so the "not found" vs "could not check" distinction is decided once (in GetWalletID)
52+
// rather than re-inferred at each call site.
53+
type WalletIDResolution struct {
54+
// Status is the outcome of the lookup. Always inspect it via Bound/Unbound/Failed
55+
// before reading the other fields.
56+
Status WalletIDStatus
57+
// WalletID is the bound wallet id; meaningful only when Status is WalletIDBound.
58+
WalletID idriver.WalletID
59+
// Err is the underlying storage failure; set only when Status is WalletIDFailed.
60+
Err error
61+
}
62+
63+
// Bound reports whether the identity has a wallet id bound to it.
64+
func (r WalletIDResolution) Bound() bool { return r.Status == WalletIDBound }
65+
66+
// Failed reports whether the storage lookup failed, leaving the binding unknown.
67+
// A failed resolution must abort the enclosing lookup, never fall through to creation.
68+
func (r WalletIDResolution) Failed() bool { return r.Status == WalletIDFailed }
69+
2570
// Registry manages wallets whose long-term identities have a given role.
2671
//
2772
// Concurrency and invariants:
@@ -82,16 +127,17 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
82127
if ok {
83128
r.Logger.DebugfContext(ctx, "lookup failed, check if there is a wallet for identity [%s]", passedIdentity)
84129
// is this identity registered
85-
wID, gwErr := r.GetWalletID(ctx, passedIdentity)
86-
if gwErr != nil {
87-
// A storage error must not be treated as "not registered": doing so would
88-
// let a transient blip fall through to wallet creation and duplicate state.
89-
return nil, nil, "", errors.WithMessagef(gwErr, "failed to lookup wallet [%s]", id)
130+
res := r.GetWalletID(ctx, passedIdentity)
131+
if res.Failed() {
132+
// A storage failure leaves the binding unknown; it must not be treated as
133+
// "not registered", or a transient blip would fall through to wallet
134+
// creation and duplicate state.
135+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
90136
}
91-
if len(wID) != 0 {
92-
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, wID)
137+
if res.Bound() {
138+
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
93139
// we got a hit
94-
walletID = wID
140+
walletID = res.WalletID
95141
ident = passedIdentity
96142
fail = false
97143
}
@@ -116,46 +162,48 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
116162
if ok {
117163
r.Logger.DebugfContext(ctx, "no wallet found, check if there is a wallet for identity [%s]", passedIdentity)
118164
// is this identity registered
119-
passedWalletID, err := r.GetWalletID(ctx, passedIdentity)
120-
if err != nil {
121-
// A storage error must not be treated as "not registered": doing so would
122-
// let a transient blip fall through to wallet creation and duplicate state.
123-
return nil, nil, "", errors.WithMessagef(err, "failed to lookup wallet [%s]", id)
165+
res := r.GetWalletID(ctx, passedIdentity)
166+
if res.Failed() {
167+
// A storage failure leaves the binding unknown; it must not be treated as
168+
// "not registered", or a transient blip would fall through to wallet
169+
// creation and duplicate state.
170+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
124171
}
125-
if len(passedWalletID) != 0 {
126-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, passedWalletID)
172+
if res.Bound() {
173+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
127174
// we got a hit
128175
r.WalletMu.RLock()
129-
walletEntry, ok = r.Wallets[passedWalletID]
176+
walletEntry, ok = r.Wallets[res.WalletID]
130177
r.WalletMu.RUnlock()
131178
if ok {
132-
return walletEntry, nil, passedWalletID, nil
179+
return walletEntry, nil, res.WalletID, nil
133180
}
134-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, passedWalletID)
181+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, res.WalletID)
135182
}
136-
walletIdentifiers = append(walletIdentifiers, passedWalletID)
183+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
137184
}
138185

139186
r.Logger.DebugfContext(ctx, "no wallet found for [%s] at [%s]", passedIdentity, logging.Prefix(wID))
140187
if len(ident) != 0 {
141-
identityWID, err := r.GetWalletID(ctx, ident)
142-
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%s]", ident, identityWID, err)
143-
if err != nil {
144-
// A storage error must not be treated as "not registered": doing so would
145-
// let a transient blip fall through to wallet creation and duplicate state.
146-
return nil, nil, "", errors.WithMessagef(err, "failed to lookup wallet [%s]", id)
188+
res := r.GetWalletID(ctx, ident)
189+
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%d]", ident, res.WalletID, res.Status)
190+
if res.Failed() {
191+
// A storage failure leaves the binding unknown; it must not be treated as
192+
// "not registered", or a transient blip would fall through to wallet
193+
// creation and duplicate state.
194+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
147195
}
148-
if len(identityWID) != 0 {
196+
if res.Bound() {
149197
r.WalletMu.RLock()
150-
w, ok := r.Wallets[identityWID]
198+
w, ok := r.Wallets[res.WalletID]
151199
r.WalletMu.RUnlock()
152200
if ok {
153-
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), identityWID)
201+
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), res.WalletID)
154202

155-
return w, nil, identityWID, nil
203+
return w, nil, res.WalletID, nil
156204
}
157205
}
158-
walletIdentifiers = append(walletIdentifiers, identityWID)
206+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
159207
}
160208

161209
for _, walletIdentifier := range walletIdentifiers {
@@ -248,21 +296,31 @@ func (r *Registry) GetIdentityMetadata(ctx context.Context, identity driver.Iden
248296
return json.Unmarshal(raw, &meta)
249297
}
250298

251-
// GetWalletID returns the wallet identifier bound to the passed identity.
299+
// GetWalletID resolves the wallet identifier bound to the passed identity.
252300
//
253-
// A storage error is propagated to the caller: an identity that has never been
254-
// bound is reported by the storage layer as ("", nil), so a non-nil error here
255-
// means a genuine storage failure (timeout, connection reset, ...). Callers must
256-
// not treat that failure as "no wallet bound", otherwise a transient blip would
257-
// look like an unregistered identity and trigger the creation of a duplicate wallet.
258-
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) (string, error) {
301+
// It is the single point that translates the storage layer's (WalletID, error)
302+
// convention into an explicit WalletIDResolution, so no caller has to re-derive the
303+
// meaning of ("", nil) vs ("", err). The storage contract reports an unbound identity
304+
// as ("", nil); a non-nil error is a genuine storage failure (timeout, connection
305+
// reset, ...) whose result is therefore WalletIDFailed, never WalletIDUnbound. Keeping
306+
// the two apart here is what prevents a transient blip from looking like an
307+
// unregistered identity and triggering the creation of a duplicate wallet (issue #2063).
308+
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) WalletIDResolution {
259309
wID, err := r.Storage.GetWalletID(ctx, identity, int(r.Role.ID()))
260310
if err != nil {
261-
return "", errors.Wrapf(err, "failed to get wallet id for identity [%s]", identity)
311+
return WalletIDResolution{
312+
Status: WalletIDFailed,
313+
Err: errors.Wrapf(err, "failed to get wallet id for identity [%s]", identity),
314+
}
315+
}
316+
if len(wID) == 0 {
317+
r.Logger.DebugfContext(ctx, "no wallet bound to identity [%s]", identity)
318+
319+
return WalletIDResolution{Status: WalletIDUnbound}
262320
}
263321
r.Logger.DebugfContext(ctx, "wallet [%s] is bound to identity [%s]", wID, identity)
264322

265-
return wID, nil
323+
return WalletIDResolution{Status: WalletIDBound, WalletID: wID}
266324
}
267325

268326
func (r *Registry) WalletByID(ctx context.Context, role idriver.IdentityRoleType, id driver.WalletLookupID) (driver.Wallet, error) {

token/services/identity/role/registry_test.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,33 @@ func TestBindIdentityAndContainsAndMetadataAndGetWalletID(t *testing.T) {
133133
require.NoError(t, reg.GetIdentityMetadata(ctx, []byte("id"), "w", &meta))
134134
require.Equal(t, "v", meta["k"])
135135

136-
// GetWalletID when storage returns value
136+
// GetWalletID when storage returns a bound wallet id.
137137
storage.GetWalletIDReturns("w", nil)
138-
wid, err := reg.GetWalletID(ctx, []byte("id"))
139-
require.NoError(t, err)
140-
require.Equal(t, "w", wid)
141-
142-
// GetWalletID when storage returns error -> error is propagated (not suppressed),
143-
// so a transient storage blip cannot masquerade as "no binding".
138+
res := reg.GetWalletID(ctx, []byte("id"))
139+
require.Equal(t, role.WalletIDBound, res.Status)
140+
require.True(t, res.Bound())
141+
require.NoError(t, res.Err)
142+
require.Equal(t, "w", res.WalletID)
143+
144+
// GetWalletID when storage authoritatively reports no binding -> WalletIDUnbound,
145+
// a clean miss with no error and no wallet id.
146+
storage.GetWalletIDReturns("", nil)
147+
resUnbound := reg.GetWalletID(ctx, []byte("id"))
148+
require.Equal(t, role.WalletIDUnbound, resUnbound.Status)
149+
require.False(t, resUnbound.Bound())
150+
require.False(t, resUnbound.Failed())
151+
require.NoError(t, resUnbound.Err)
152+
require.Empty(t, resUnbound.WalletID)
153+
154+
// GetWalletID when storage fails -> WalletIDFailed with the error preserved, so a
155+
// transient storage blip cannot masquerade as "no binding".
144156
storage.GetWalletIDReturns("", errors.New("boom"))
145-
wid2, err2 := reg.GetWalletID(ctx, []byte("id"))
146-
require.Error(t, err2)
147-
require.Empty(t, wid2)
157+
resFailed := reg.GetWalletID(ctx, []byte("id"))
158+
require.Equal(t, role.WalletIDFailed, resFailed.Status)
159+
require.True(t, resFailed.Failed())
160+
require.False(t, resFailed.Bound())
161+
require.Error(t, resFailed.Err)
162+
require.Empty(t, resFailed.WalletID)
148163
}
149164

150165
// TestLookup_StorageErrorDoesNotFallThrough pins the fix for #2063: a transient

0 commit comments

Comments
 (0)