Repository: meltano/sdk (python)

Issue:
bug: JSON schema references are incorrectly resolved when mulitple properties use the same reference
### Singer SDK Version

0.46.4

### Is this a regression?

- [x] Yes

### Python Version

NA

### Bug scope

Taps (catalog, state, etc.)

### Operating System

NA

### Description

```python
from singer_sdk.singerlib resolve_schema_references

resolve_schema_references(
    {
        "type": "object",
        "properties": {
            "min_compute_units": {"$ref": "components#/schemas/ComputeUnit"},
             "max_compute_units": {"$ref": "components#/schemas/ComputeUnit"},
        },
    },
    {"components": {"schemas": {"ComputeUnit": {"type": "number"}}}},
)

# Expected:
# {
#     "type": "object",
#     "properties": {
#         "min_compute_units": {"type": "number"},
#         "max_compute_units": {"type": "number"},
#     },
# }

# Actual
# {
#     "type": "object",
#     "properties": {
#         "min_compute_units": {"type": "number"},
#         "max_compute_units": {},  # Presumably ComputeUnit had already been visited
#     },
# }
```


### Link to Slack/Linen

_No response_

Pull request:
fix: Correctly resolve JSON schema references when mulitple properties use the same reference
## Related

- Closes #3090
- Introduced by #3009

## Summary by Sourcery

Ensure JSON schema references are correctly dereferenced when multiple properties refer to the identical component to prevent shared-state issues

Bug Fixes:
- Correct JSON schema resolution when multiple properties reference the same schema component

Tests:
- Add a test case for resolving multiple properties using the same JSON schema reference

## Summary by Sourcery

Fix JSON schema reference resolution to correctly dereference identical components for multiple properties without shared-state issues

Bug Fixes:
- Ensure separate resolution of identical schema references used by multiple properties to prevent state leakage

Enhancements:
- Switch visited_refs from a mutable set to an immutable tuple to accurately track recursion depth

Tests:
- Add a test case for resolving multiple properties referencing the same JSON schema component

Patch:
diff --git a/singer_sdk/singerlib/schema.py b/singer_sdk/singerlib/schema.py
index 035851fe..7754f835 100644
--- a/singer_sdk/singerlib/schema.py
+++ b/singer_sdk/singerlib/schema.py
@@ -254,33 +254,34 @@ def resolve_schema_references(
 def _resolve_schema_references(  # noqa: C901, PLR0912
     schema: dict[str, t.Any],
     resolver: Resolver,
-    visited_refs: set[str] | None = None,
+    visited_refs: tuple[str, ...] | None = None,
 ) -> dict[str, t.Any]:
     """Recursively resolve schema references while handling circular references.
 
     Args:
         schema: The schema dict to resolve references in
         resolver: The JSON Schema resolver to use
-        visited_refs: Set of already visited reference paths to prevent infinite
-            recursion
+        visited_refs: Tuple of already visited reference paths in the current call stack
+            to prevent infinite recursion
 
     Returns:
         The schema with all references resolved
     """
     if visited_refs is None:
-        visited_refs = set()
+        visited_refs = ()
 
     if _SchemaKey.ref in schema:
         reference_path = schema.pop(_SchemaKey.ref, None)
         if reference_path in visited_refs:
-            # We've already seen this reference, return the schema as-is
-            # to prevent infinite recursion
+            # We've already seen this reference in the current call stack, return
+            # the schema as-is to prevent infinite recursion
             return schema
 
-        visited_refs.add(reference_path)
+        # Add this reference to the current call stack
+        new_visited_refs = (*visited_refs, reference_path)
         resolved = resolver.lookup(reference_path)
         schema.update(resolved.contents)
-        return _resolve_schema_references(schema, resolver, visited_refs)
+        return _resolve_schema_references(schema, resolver, new_visited_refs)
 
     if _SchemaKey.properties in schema:
         for k, val in schema[_SchemaKey.properties].items():


Test patch:
diff --git a/tests/singerlib/test_schema.py b/tests/singerlib/test_schema.py
index 89c22d8d..4b3fa28c 100644
--- a/tests/singerlib/test_schema.py
+++ b/tests/singerlib/test_schema.py
@@ -415,6 +415,30 @@ def test_schema_from_dict(pydict, expected):
             },
             id="resolve_schema_references_with_circular_references",
         ),
+        pytest.param(
+            {
+                "type": "object",
+                "properties": {
+                    "min_compute_units": {"$ref": "components#/schemas/ComputeUnit"},
+                    "max_compute_units": {"$ref": "components#/schemas/ComputeUnit"},
+                },
+            },
+            {
+                "components": {
+                    "schemas": {
+                        "ComputeUnit": {"type": "number"},
+                    },
+                },
+            },
+            {
+                "type": "object",
+                "properties": {
+                    "min_compute_units": {"type": "number"},
+                    "max_compute_units": {"type": "number"},
+                },
+            },
+            id="resolve_schema_multiple_properties_with_same_reference",
+        ),
     ],
 )
 def test_resolve_schema_references(schema, refs, expected):