diff --git a/openapi/reference.go b/openapi/reference.go index 4c30776..7aecb76 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "sync" @@ -290,15 +291,38 @@ func (r *Reference[T, V, C]) GetObject() *T { return r.Object } - r.ensureMutex() - r.cacheMutex.RLock() - defer r.cacheMutex.RUnlock() + // Walk the resolution chain rather than recursing through it. A reference + // whose pointer names a reference already in the chain publishes a cache + // entry that closes the loop: a reference can resolve to itself, and two + // references can resolve to each other. Recursing through that exhausts the + // goroutine stack, so track what has been seen and report the reference as + // unresolved instead. + // + // The array sizes the common case; deeper chains grow onto the heap. + var backing [8]*Reference[T, V, C] + seen := backing[:0] - if (r.referenceResolutionCache != nil && r.referenceResolutionCache.Object != nil) || r.circularErrorFound { - if r.referenceResolutionCache != nil && r.referenceResolutionCache.Object != nil { - return r.referenceResolutionCache.Object.GetObject() + for current := r; current != nil; { + if !current.IsReference() { + return current.Object } + + if slices.Contains(seen, current) { + return nil + } + seen = append(seen, current) + + current.ensureMutex() + current.cacheMutex.RLock() + cache := current.referenceResolutionCache + current.cacheMutex.RUnlock() + + if cache == nil { + return nil + } + current = cache.Object } + return nil } @@ -377,6 +401,8 @@ func (r *Reference[T, V, C]) GetParent() *Reference[T, V, C] { if r == nil { return nil } + referenceParentMutex.RLock() + defer referenceParentMutex.RUnlock() return r.parent } @@ -393,6 +419,8 @@ func (r *Reference[T, V, C]) GetTopLevelParent() *Reference[T, V, C] { if r == nil { return nil } + referenceParentMutex.RLock() + defer referenceParentMutex.RUnlock() return r.topLevelParent } @@ -406,6 +434,8 @@ func (r *Reference[T, V, C]) SetParent(parent *Reference[T, V, C]) { if r == nil { return } + referenceParentMutex.Lock() + defer referenceParentMutex.Unlock() r.parent = parent } @@ -419,6 +449,8 @@ func (r *Reference[T, V, C]) SetTopLevelParent(topLevelParent *Reference[T, V, C if r == nil { return } + referenceParentMutex.Lock() + defer referenceParentMutex.Unlock() r.topLevelParent = topLevelParent } @@ -533,17 +565,24 @@ func (r *Reference[T, V, C]) resolve(ctx context.Context, opts references.Resolv } r.cacheMutex.RUnlock() - // Need to resolve (with write lock) + // Need to resolve, and that has to happen with the lock released: + // references.Resolve navigates the document and calls GetObject on + // references it traverses, which takes a read lock. sync.RWMutex is not + // reentrant, so holding this reference's lock across that call + // self-deadlocks when the traversal reaches it, directly or via a cache + // forward. r.cacheMutex.Lock() - defer r.cacheMutex.Unlock() + cache := r.referenceResolutionCache + cachedErrs := r.validationErrsCache + r.cacheMutex.Unlock() - // Double-check after acquiring write lock - if r.referenceResolutionCache != nil { - if r.referenceResolutionCache.Object.IsReference() { - return nil, r.referenceResolutionCache.Object, r.validationErrsCache, nil - } else { - return r.referenceResolutionCache.Object.Object, nil, r.validationErrsCache, nil + // Double-check: another goroutine may have published between the read above + // and here. + if cache != nil { + if cache.Object.IsReference() { + return nil, cache.Object, cachedErrs, nil } + return cache.Object.Object, nil, cachedErrs, nil } rootDoc, ok := opts.RootDocument.(*OpenAPI) @@ -565,6 +604,16 @@ func (r *Reference[T, V, C]) resolve(ctx context.Context, opts references.Resolv return nil, nil, validationErrs, err } + // Re-acquire to publish the result. + r.cacheMutex.Lock() + defer r.cacheMutex.Unlock() + if r.referenceResolutionCache != nil { + if r.referenceResolutionCache.Object.IsReference() { + return nil, r.referenceResolutionCache.Object, r.validationErrsCache, nil + } + return r.referenceResolutionCache.Object.Object, nil, r.validationErrsCache, nil + } + r.referenceResolutionCache = result r.validationErrsCache = validationErrs @@ -624,17 +673,8 @@ func resolveObjectWithTracking[T any, V interfaces.Validator[T], C marshaller.Co // If we got another reference, recursively resolve it with the resolved document as the new target if nextRef != nil { - // Set parent links for the resolved reference - // The resolved reference's parent is the current reference - // The top-level parent is either the current reference's top-level parent, or the current reference if it's the top-level - var topLevel *Reference[T, V, C] - if ref.topLevelParent != nil { - topLevel = ref.topLevelParent - } else { - topLevel = ref - } - nextRef.SetParent(ref) - nextRef.SetTopLevelParent(topLevel) + // Record that nextRef was reached by resolving ref. + linkResolvedParent(ref, nextRef) // For chained resolutions, we need to use the resolved document from the previous step // The ResolveResult.ResolvedDocument should be used as the new TargetDocument @@ -652,6 +692,66 @@ func resolveObjectWithTracking[T any, V interfaces.Validator[T], C marshaller.Co return validationErrs, fmt.Errorf("unable to resolve reference: %s", ref.GetReference()) } +// linkResolvedParent records that nextRef was reached by resolving ref: nextRef's +// parent becomes ref, and its top-level parent becomes the head of ref's chain. +// +// The link is skipped when nextRef is already an ancestor of ref, because +// parenting a reference to its own descendant closes a loop in the links that +// GetParent and GetTopLevelParent expose to callers. A pointer can name a +// reference the chain has been through -- its own, or one an earlier hop went +// through -- and each member of a cycle can be resolved by a separate call, so +// ancestry is read from the links rather than from any one call's chain. The +// resolution tracker reports the cycle either way; leaving the links alone keeps +// them walkable meanwhile. +// +// The check and both writes are one critical section. Resolvers running +// concurrently would otherwise each see no ancestry and then publish opposite +// edges, rebuilding exactly the cycle the check exists to prevent. +func linkResolvedParent[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ref, nextRef *Reference[T, V, C]) { + if ref == nil || nextRef == nil { + return + } + + referenceParentMutex.Lock() + defer referenceParentMutex.Unlock() + + topLevel := ref.topLevelParent + if topLevel == nil { + topLevel = ref + } + + if topLevel == nextRef || isAncestorLocked(ref, nextRef) { + return + } + + nextRef.parent = ref + nextRef.topLevelParent = topLevel +} + +// isAncestorLocked reports whether candidate is ref itself or is reachable from +// ref by following parent links, which is what makes candidate unsafe to parent +// to ref. Callers must hold referenceParentMutex; the walk reads the fields +// directly rather than through GetParent, which would deadlock on it. +// +// The links are acyclic, so the walk terminates; the seen set is there so that a +// graph left cyclic by an older version stops the walk rather than hanging. +func isAncestorLocked[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ref, candidate *Reference[T, V, C]) bool { + var backing [8]*Reference[T, V, C] + seen := backing[:0] + + for current := ref; current != nil; current = current.parent { + if current == candidate { + return true + } + if slices.Contains(seen, current) { + return false + } + seen = append(seen, current) + } + + return false +} + // joinReferenceChain joins the reference chain with arrows to show the circular path func joinReferenceChain(chain []string) string { if len(chain) == 0 { @@ -689,6 +789,17 @@ func unmarshaler[T any, V interfaces.Validator[T], C marshaller.CoreModeler](_ * // to avoid data races on r.initMutex when ensureMutex is called concurrently. var referenceInitGlobalMutex sync.Mutex +// referenceParentMutex guards the parent and topLevelParent links of every +// Reference. +// +// The links form one graph rather than per-reference state: deciding whether an +// edge is safe to add means reading links that belong to other references, so a +// per-reference lock could not make that check and the write that follows it a +// single operation. One lock over the graph can. The links are only touched +// when a reference is resolved or when a caller builds a chain by hand, so the +// contention this trades for is negligible against the work of resolving. +var referenceParentMutex sync.RWMutex + // ensureMutex initializes the mutex if it's nil (lazy initialization). // Uses sync.Once (pointer) to guarantee thread-safe single initialization // while keeping Reference safe to copy before first use. diff --git a/openapi/reference_mutex_test.go b/openapi/reference_mutex_test.go index 269960a..ae6e69b 100644 --- a/openapi/reference_mutex_test.go +++ b/openapi/reference_mutex_test.go @@ -1,11 +1,14 @@ package openapi import ( + "strings" "sync" "testing" + "time" "github.com/speakeasy-api/openapi/openapi/core" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestEnsureMutex_ConcurrentAccess verifies that ensureMutex is safe to call @@ -55,3 +58,509 @@ func TestEnsureMutex_CopiedReference(t *testing.T) { assert.NotNil(t, original.initMutex, "original initMutex should still be set") assert.NotNil(t, original.cacheMutex, "original cacheMutex should still be set") } + +// TestResolveAllReferences_PointerTraversingItsOwnReference verifies that a +// $ref whose JSON pointer passes through the reference being resolved does not +// deadlock. +// +// Reference.resolve used to hold the reference's own write lock across +// references.Resolve. That call navigates the document, and navigating into a +// reference calls GetObject, which takes a read lock. sync.RWMutex is not +// reentrant, so a pointer whose prefix named the reference being resolved +// blocked forever on a lock its own goroutine held. +// +// Each case below is a document that hung before the fix. +// +// Resolution is only half of it. A pointer that names a reference already in +// the chain also leaves that reference's resolution cache pointing back into +// the chain, so every case asserts GetObject afterwards: walking a cycle there +// exhausts the goroutine stack, which aborts the test binary outright rather +// than failing a single case. +func TestResolveAllReferences_PointerTraversingItsOwnReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + expectedErr string + }{ + { + name: "prefix names the reference being resolved", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/paths/~1a/t'} +`, + expectedErr: "unresolved reference", + }, + { + name: "prefix names the reference and the pointer resolves", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1a/get' + get: {operationId: a, responses: {"200": {description: ok}}} +`, + expectedErr: "unresolved reference", + }, + { + name: "prefix reaches the in-flight reference through a resolved one", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/paths/~1b'} + /b: {$ref: '#/paths/~1a/t'} +`, + expectedErr: "unresolved reference", + }, + { + name: "components spelling", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/components/pathItems/A'} +components: + pathItems: + A: {$ref: '#/components/pathItems/A/t'} +`, + expectedErr: "unresolved reference", + }, + { + name: "webhooks spelling", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: {} +webhooks: + onA: {$ref: '#/webhooks/onA/t'} +`, + expectedErr: "unresolved reference", + }, + { + // GetJSONPointer trims the pointer, so this names /a and the + // reference resolves to itself. The tracker reports that, but the + // reference is left holding a resolution cache that points at + // itself, so it is GetObject below that this case guards. + name: "reference resolving to itself via a trimmed pointer", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1a ' + get: {operationId: a, responses: {"200": {description: ok}}} +`, + expectedErr: "circular reference detected: test.yaml#/paths/~1a -> test.yaml#/paths/~1a", + }, + { + // Three nodes, so the cycle closes a hop beyond anything a + // pairwise check would notice. + name: "three references forming a cycle via trimmed pointers", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1b ' + get: {operationId: a, responses: {"200": {description: ok}}} + /b: + $ref: '#/paths/~1c ' + get: {operationId: b, responses: {"200": {description: ok}}} + /c: + $ref: '#/paths/~1a ' + get: {operationId: c, responses: {"200": {description: ok}}} +`, + expectedErr: "circular reference detected: test.yaml#/paths/~1b -> test.yaml#/paths/~1c -> test.yaml#/paths/~1a -> test.yaml#/paths/~1b", + }, + { + // Same shape one hop wider: /a's cache points at /b and /b's points + // back at /a. The tracker only notices on the third hop, by which + // point both caches are published, so neither reference is a + // self-reference and the cycle only shows up when walking them. + name: "two references resolving to each other via trimmed pointers", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1b ' + get: {operationId: a, responses: {"200": {description: ok}}} + /b: + $ref: '#/paths/~1a ' + get: {operationId: b, responses: {"200": {description: ok}}} +`, + expectedErr: "circular reference detected: test.yaml#/paths/~1b -> test.yaml#/paths/~1a -> test.yaml#/paths/~1b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(tt.spec)) + require.NoError(t, err) + + type result struct { + resolveErrs []error + err error + } + done := make(chan result, 1) + go func() { + resolveErrs, err := doc.ResolveAllReferences(ctx, ResolveAllOptions{ + OpenAPILocation: "test.yaml", + DisableExternalRefs: true, + }) + done <- result{resolveErrs: resolveErrs, err: err} + }() + + select { + case got := <-done: + require.Error(t, got.err) + assert.Contains(t, got.err.Error(), tt.expectedErr) + assert.Empty(t, got.resolveErrs) + case <-time.After(30 * time.Second): + t.Fatal("ResolveAllReferences deadlocked resolving a reference whose pointer traverses itself") + } + + // None of these resolved, so none of them have an object. Reaching + // that verdict must not walk a cycle, and neither must walking the + // parent links the failed resolution left behind. + for path, pathItem := range doc.Paths.All() { + assert.Nil(t, pathItem.GetObject(), "path %s should have no resolved object", path) + assertParentLinksTerminate(t, path, pathItem) + } + for name, webhook := range doc.Webhooks.All() { + assert.Nil(t, webhook.GetObject(), "webhook %s should have no resolved object", name) + assertParentLinksTerminate(t, name, webhook) + } + }) + } +} + +// assertParentLinksTerminate walks the parent links a resolution attempt left +// on ref and fails if they lead back to a reference already walked. A failed +// resolution still publishes links, and callers reach them through the public +// GetParent and GetTopLevelParent. +func assertParentLinksTerminate(t *testing.T, label string, ref *ReferencedPathItem) { + t.Helper() + + seen := map[*ReferencedPathItem]bool{} + for current := ref; current != nil; current = current.GetParent() { + require.False(t, seen[current], "%s: parent links cycle", label) + seen[current] = true + } + + // The top-level parent is the head of the chain, never the reference itself. + assert.NotSame(t, ref, ref.GetTopLevelParent(), "%s: top-level parent points at itself", label) +} + +// TestResolve_SeparateCallsOverCycle covers the parent links when each member of +// a cycle is resolved by its own call to the public Resolve. +// +// ResolveAllReferences walks the document once and skips references already +// marked resolved, so it never revisits the second member. Resolve has no such +// guard: it starts a fresh chain every time, and the links from the earlier call +// are the only record that the two references already descend from each other. +func TestResolve_SeparateCallsOverCycle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + order []string + }{ + { + name: "two-node cycle, /a first", + spec: twoNodeCycleSpec, + order: []string{"/a", "/b"}, + }, + { + name: "two-node cycle, /b first", + spec: twoNodeCycleSpec, + order: []string{"/b", "/a"}, + }, + { + name: "three-node cycle, every member in turn", + spec: threeNodeCycleSpec, + order: []string{"/a", "/b", "/c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(tt.spec)) + require.NoError(t, err) + + opts := ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "test.yaml", + DisableExternalRefs: true, + } + + // Every call reports the cycle; it is what they leave behind that matters. + for _, path := range tt.order { + ref, ok := doc.Paths.Get(path) + require.True(t, ok) + + _, err := ref.Resolve(ctx, opts) + require.Error(t, err, "resolving %s", path) + assert.Contains(t, err.Error(), "circular reference detected") + } + + for path, ref := range doc.Paths.All() { + assertParentLinksTerminate(t, path, ref) + assert.Nil(t, ref.GetObject(), "%s should have no resolved object", path) + } + }) + } +} + +// TestResolve_ValidChainParentLinks pins the parent links a chain that resolves +// cleanly is expected to leave, so that guarding against cycles cannot quietly +// start dropping legitimate links. Resolving twice must not disturb them. +func TestResolve_ValidChainParentLinks(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(validChainSpec)) + require.NoError(t, err) + + opts := ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "test.yaml", + DisableExternalRefs: true, + } + + root, ok := doc.Paths.Get("/a") + require.True(t, ok) + hopA, ok := doc.Components.PathItems.Get("A") + require.True(t, ok) + hopB, ok := doc.Components.PathItems.Get("B") + require.True(t, ok) + + for i := range 2 { + _, err := root.Resolve(ctx, opts) + require.NoError(t, err, "resolve %d", i+1) + + obj := root.GetObject() + require.NotNil(t, obj, "resolve %d", i+1) + assert.Equal(t, "c", obj.Get().GetOperationID(), "resolve %d", i+1) + + // /a heads the chain, so it has no parent of its own. + assert.Nil(t, root.GetParent()) + assert.Nil(t, root.GetTopLevelParent()) + + assert.Same(t, root, hopA.GetParent()) + assert.Same(t, root, hopA.GetTopLevelParent()) + + assert.Same(t, hopA, hopB.GetParent()) + assert.Same(t, root, hopB.GetTopLevelParent()) + } +} + +// TestResolve_ConcurrentCallsOverCycle starts both members of a cycle resolving +// together. Each resolver decides whether an edge is safe by reading links that +// the other is writing, so the check and the write have to be one operation: two +// resolvers that both see no ancestry would otherwise publish opposite edges and +// rebuild the cycle between them. +func TestResolve_ConcurrentCallsOverCycle(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(twoNodeCycleSpec)) + require.NoError(t, err) + + opts := ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "test.yaml", + DisableExternalRefs: true, + } + + pathA, ok := doc.Paths.Get("/a") + require.True(t, ok) + pathB, ok := doc.Paths.Get("/b") + require.True(t, ok) + + // A barrier, so both resolvers are inside the check at the same time. + // Sequencing them would not exercise anything the tests above do not. + start := make(chan struct{}) + var wg sync.WaitGroup + + for _, ref := range []*ReferencedPathItem{pathA, pathB} { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, _ = ref.Resolve(ctx, opts) + }() + } + + close(start) + wg.Wait() + + for path, ref := range doc.Paths.All() { + assertParentLinksTerminate(t, path, ref) + assert.Nil(t, ref.GetObject(), "%s should have no resolved object", path) + } +} + +// TestResolve_ConcurrentCallsSameReference resolves one valid reference from many +// goroutines at once. Releasing the lock during resolution lets the work happen +// more than once; every caller still has to end up with the resolved object. +func TestResolve_ConcurrentCallsSameReference(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(validChainSpec)) + require.NoError(t, err) + + opts := ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "test.yaml", + DisableExternalRefs: true, + } + + root, ok := doc.Paths.Get("/a") + require.True(t, ok) + + const resolvers = 16 + + start := make(chan struct{}) + errs := make([]error, resolvers) + objs := make([]*PathItem, resolvers) + + var wg sync.WaitGroup + for i := range resolvers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, errs[i] = root.Resolve(ctx, opts) + objs[i] = root.GetObject() + }() + } + + close(start) + wg.Wait() + + for i := range resolvers { + require.NoError(t, errs[i], "resolver %d", i) + require.NotNil(t, objs[i], "resolver %d", i) + assert.Equal(t, "c", objs[i].Get().GetOperationID(), "resolver %d", i) + } + + assertParentLinksTerminate(t, "/a", root) +} + +const twoNodeCycleSpec = `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1b ' + get: {operationId: a, responses: {"200": {description: ok}}} + /b: + $ref: '#/paths/~1a ' + get: {operationId: b, responses: {"200": {description: ok}}} +` + +const threeNodeCycleSpec = `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: + $ref: '#/paths/~1b ' + get: {operationId: a, responses: {"200": {description: ok}}} + /b: + $ref: '#/paths/~1c ' + get: {operationId: b, responses: {"200": {description: ok}}} + /c: + $ref: '#/paths/~1a ' + get: {operationId: c, responses: {"200": {description: ok}}} +` + +const validChainSpec = `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/components/pathItems/A'} +components: + pathItems: + A: {$ref: '#/components/pathItems/B'} + B: {$ref: '#/components/pathItems/C'} + C: {get: {operationId: c, responses: {"200": {description: ok}}}} +` + +// TestGetObject_ChainWalking covers the two ends of GetObject's chain walk: a +// chain of references that terminates has to be followed all the way to the +// object, and one that does not terminate has to give up. +func TestGetObject_ChainWalking(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + expectedOp string + expectedErr string + }{ + { + name: "multi-hop chain reaches the object", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/components/pathItems/A'} +components: + pathItems: + A: {$ref: '#/components/pathItems/B'} + B: {$ref: '#/components/pathItems/C'} + C: {get: {operationId: c, responses: {"200": {description: ok}}}} +`, + expectedOp: "c", + }, + { + name: "circular chain reports no object", + spec: `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /a: {$ref: '#/components/pathItems/A'} +components: + pathItems: + A: {$ref: '#/components/pathItems/B'} + B: {$ref: '#/components/pathItems/A'} +`, + expectedErr: "circular reference detected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, _, err := Unmarshal(ctx, strings.NewReader(tt.spec)) + require.NoError(t, err) + + _, err = doc.ResolveAllReferences(ctx, ResolveAllOptions{ + OpenAPILocation: "test.yaml", + DisableExternalRefs: true, + }) + + pathItem, ok := doc.Paths.Get("/a") + require.True(t, ok) + + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, pathItem.GetObject()) + return + } + + require.NoError(t, err) + obj := pathItem.GetObject() + require.NotNil(t, obj) + assert.Equal(t, tt.expectedOp, obj.Get().GetOperationID()) + }) + } +}