From 2ccea68f1963f4d64e9902cf43bd7cb489050574 Mon Sep 17 00:00:00 2001 From: Dylan Isaac Date: Fri, 24 Jul 2026 02:18:55 -0400 Subject: [PATCH] perf: reuse config alias traversal results --- CHANGELOG.md | 8 +++- browser-extension/background.js | 2 +- browser-extension/manifest.json | 2 +- docs/changelog.md | 6 +++ pyproject.toml | 2 +- src/gobbler_cli/__init__.py | 2 +- src/gobbler_core/__init__.py | 2 +- src/gobbler_core/config.py | 21 +++++++-- src/gobbler_queue/__init__.py | 2 +- tests/unit/test_browser_extension_static.py | 2 +- tests/unit/test_config.py | 49 +++++++++++++++++++++ 11 files changed, 87 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f70ab2..e9d42ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.32] - 2026-07-24 + +### Changed +- Reused replacement-closure results while cloning repeated acyclic configuration aliases, reducing traversal work from quadratic to linear without changing merge identity or isolation semantics. + ## [0.2.31] - 2026-07-23 ### Fixed @@ -263,7 +268,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Code quality tools including Ruff and mypy - Documentation and browser extension setup guides -[Unreleased]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.31...HEAD +[Unreleased]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.32...HEAD +[0.2.32]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.31...v0.2.32 [0.2.31]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.30...v0.2.31 [0.2.30]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.29...v0.2.30 [0.2.29]: https://github.com/Enablement-Engineering/gobbler/compare/v0.2.28...v0.2.29 diff --git a/browser-extension/background.js b/browser-extension/background.js index 71d1c1a..e036546 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -564,7 +564,7 @@ function connectWebSocket() { // Send registration message ws.send(JSON.stringify({ type: 'register', - extension_version: '0.2.31', + extension_version: '0.2.32', })); // Clear reconnect interval if it exists diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index c481536..bd38947 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Gobbler", - "version": "0.2.31", + "version": "0.2.32", "description": "Extract, navigate, and automate tabs in Gobbler's managed browser group", "permissions": [ "activeTab", diff --git a/docs/changelog.md b/docs/changelog.md index 71b8e24..afa7d53 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,12 @@ icon: material/history All notable changes to this project are tracked in the root [CHANGELOG.md](https://github.com/Enablement-Engineering/gobbler/blob/main/CHANGELOG.md). +## [0.2.32] - 2026-07-24 + +### Changed + +- Reused replacement-closure results while cloning repeated acyclic configuration aliases, reducing traversal work from quadratic to linear without changing merge identity or isolation semantics. + ## [0.2.31] - 2026-07-23 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 4fb7d03..039f93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gobbler" -version = "0.2.31" +version = "0.2.32" description = "Universal content conversion fabric - convert YouTube, web, documents, audio/video to markdown via CLI and skills" authors = [ {name = "Dylan Isaac", email = "dylan@enablement.engineering"} diff --git a/src/gobbler_cli/__init__.py b/src/gobbler_cli/__init__.py index 548d9b5..1c54465 100644 --- a/src/gobbler_cli/__init__.py +++ b/src/gobbler_cli/__init__.py @@ -12,6 +12,6 @@ $ gobbler relay start """ -__version__ = "0.2.31" +__version__ = "0.2.32" __all__ = ["__version__"] diff --git a/src/gobbler_core/__init__.py b/src/gobbler_core/__init__.py index 145c43d..c7d1dc7 100644 --- a/src/gobbler_core/__init__.py +++ b/src/gobbler_core/__init__.py @@ -20,7 +20,7 @@ Converters live in this package and are used by the CLI. """ -__version__ = "0.2.31" +__version__ = "0.2.32" # Convenience imports for common use cases from gobbler_core.providers.youtube import ( diff --git a/src/gobbler_core/config.py b/src/gobbler_core/config.py index e00881b..b203e1a 100644 --- a/src/gobbler_core/config.py +++ b/src/gobbler_core/config.py @@ -589,9 +589,14 @@ def _clone_projected_override( active: dict[int, dict[str, Any]], shared: dict[int, Any], projection: dict[int, Any], + replacement_cache: dict[int, set[int]], ) -> Any: """Clone a value, isolating only paths that reference an active merge projection.""" - marked = _ConfigLoader._replacement_closure(value, active) + value_id = id(value) + marked = replacement_cache.get(value_id) + if marked is None: + marked = _ConfigLoader._replacement_closure(value, active) + replacement_cache[value_id] = marked if id(value) not in marked: return Config._clone_override_value(value, active, shared) @@ -689,11 +694,19 @@ def _deep_merge( int, dict[str, Any], dict[int, Any], + dict[int, set[int]], tuple[int, int] | None, ] - ] = [(iter(override.items()), override_id, result, {}, None)] + ] = [(iter(override.items()), override_id, result, {}, {}, None)] while stack: - iterator, current_override_id, target, projection, completed_key = stack[-1] + ( + iterator, + current_override_id, + target, + projection, + replacement_cache, + completed_key, + ) = stack[-1] try: key, value = next(iterator) except StopIteration: @@ -727,6 +740,7 @@ def _deep_merge( value_id, child, {}, + {}, pair if cacheable else None, ) ) @@ -737,6 +751,7 @@ def _deep_merge( active, cloned, projection, + replacement_cache, ) return result diff --git a/src/gobbler_queue/__init__.py b/src/gobbler_queue/__init__.py index 54774db..a523585 100644 --- a/src/gobbler_queue/__init__.py +++ b/src/gobbler_queue/__init__.py @@ -29,7 +29,7 @@ from .models import Job, JobStatus, JobSummary, JobType from .worker import Worker -__version__ = "0.2.31" +__version__ = "0.2.32" __all__ = [ "Database", diff --git a/tests/unit/test_browser_extension_static.py b/tests/unit/test_browser_extension_static.py index 5c0b96a..1418bcc 100644 --- a/tests/unit/test_browser_extension_static.py +++ b/tests/unit/test_browser_extension_static.py @@ -138,7 +138,7 @@ def test_background_command_contract_matches_relay_client_helpers() -> None: assert "command_id: command_id" in background assert "result: result" in background assert "type: 'register'" in background - assert "extension_version: '0.2.31'" in background + assert "extension_version: '0.2.32'" in background assert "type: 'ping'" in background assert "message.type === 'pong'" in background diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index df2c92a..ef5feb5 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1058,6 +1058,55 @@ def test_cycle_detection_memoizes_matching_cycle_members(self) -> None: assert Config._has_container_cycle(nodes[0], cache) assert all(cache[id(node)] for node in nodes) + def test_deep_merge_clones_shared_acyclic_payload_once(self) -> None: + """Test repeated aliases do not retraverse an already cloned payload.""" + + class CountingList(list[object]): + yielded = 0 + + def __iter__(self): + for item in super().__iter__(): + type(self).yielded += 1 + yield item + + alias_count = 80 + payload = CountingList([[] for _index in range(alias_count)]) + + merged = Config._deep_merge( + {}, + {str(index): payload for index in range(alias_count)}, + ) + + assert all(merged[str(index)] is merged["0"] for index in range(alias_count)) + assert merged["0"] is not payload + assert CountingList.yielded <= alias_count * 5 + + def test_deep_merge_reuses_alias_scan_after_sibling_mapping_merges(self) -> None: + """Test sibling merge frames do not evict an outer frame's alias scan.""" + + class CountingList(list[object]): + yielded = 0 + + def __iter__(self): + for item in super().__iter__(): + type(self).yielded += 1 + yield item + + alias_count = 80 + payload = CountingList([[] for _index in range(alias_count)]) + base: dict[str, object] = {} + override: dict[str, object] = {} + for index in range(alias_count): + override[f"alias_{index}"] = payload + base[f"merge_{index}"] = {"base": index} + override[f"merge_{index}"] = {"override": index} + + merged = Config._deep_merge(base, override) + + assert all(merged[f"alias_{index}"] is merged["alias_0"] for index in range(alias_count)) + assert merged["alias_0"] is not payload + assert CountingList.yielded <= alias_count * 5 + def test_deep_merge_clones_shared_empty_mutable_overrides(self) -> None: """Test empty mutable override containers are cloned once without mutating input.""" shared_list: list[object] = []