Add hermetic template recording and replay - #1975
Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR implements a comprehensive hermetic recording system for Liquid templates that enables perfect deterministic replay of template executions with massive size optimizations. The system captures complete template execution state - including all variable access patterns, filter calls, and file system interactions - and stores it in optimized JSON recordings for testing, debugging, and performance analysis.
Key changes:
- Implements hermetic template recording and replay with drop-free architecture
- Adds semantic key-based filter recording system for ~95% size reduction
- Provides TrackableHash/Array wrappers for comprehensive variable interaction capture
Reviewed Changes
Copilot reviewed 31 out of 33 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/unit/template_recorder_unit_test.rb | Comprehensive unit tests for template recorder functionality |
| test/unit/memory_file_system_unit_test.rb | Unit tests for in-memory file system component |
| test/unit/json_schema_unit_test.rb | Unit tests for JSON schema validation and serialization |
| test/unit/event_log_unit_test.rb | Unit tests for event logging and path parsing |
| test/unit/cli_roundtrip_test.rb | CLI integration tests for recording/replay workflow |
| test/unit/binding_tracker_unit_test.rb | Unit tests for object binding and loop context tracking |
| test/test_helper.rb | Minor parameter update for file system compatibility |
| test/integration/template_recorder_integration_test.rb | Integration tests for complex template scenarios |
| test/integration/tags/include_tag_test.rb | Updates file system interface for context parameter |
| test/integration/profiler_test.rb | Updates file system interface for context parameter |
| test/integration/error_handling_test.rb | Updates file system interface for context parameter |
| performance/theme_runner.rb | Refactored to support individual test execution and recording |
| performance/shopify/vision.database.yml | Database formatting improvements for better readability |
| performance/shopify/database.rb | Adds ProductDrop class for proper Liquid Drop object handling |
| performance/profile.rb | Enhanced profiling with better output and flame graph support |
| performance/memory_profile.rb | Updates to use refactored theme runner methods |
| performance/benchmark.rb | Updates to use refactored theme runner methods and YJIT detection |
| lib/liquid/template_recorder/* | Core recorder implementation with replayer, memory filesystem, and schema |
| lib/liquid/strainer_template.rb | Adds filter call recording hooks |
| lib/liquid/tags/for.rb | Adds loop event recording for proper iteration tracking |
| lib/liquid/partial_cache.rb | Updates file system interface for context parameter |
| lib/liquid/file_system.rb | Adds file read recording hooks and context parameter |
| lib/liquid/drop.rb | Adds drop property access recording hooks |
| lib/liquid.rb | Adds template recorder module require |
| RECORDER_IMPLEMENTATION_PLAN.md | Comprehensive implementation documentation |
Comments suppressed due to low confidence (6)
test/unit/template_recorder_unit_test.rb:252
- This puts statement in a test method will produce output during test execution. Consider using a proper assertion or removing debug output.
puts "Replayed #{found_items} out of #{expected_items.length} expected items" if found_items < expected_items.length
test/unit/template_recorder_unit_test.rb:423
- This puts statement in a test method will produce output during test execution. Consider using a proper assertion or removing debug output.
puts "Root recorded as array due to loop recording behavior"
test/unit/template_recorder_unit_test.rb:499
- This puts statement in a test method will produce output during test execution. Consider using a proper assertion or removing debug output.
puts "Items available for replay: #{root_vars.map { |item| item['name'] }.join(', ')}"
test/unit/template_recorder_unit_test.rb:522
- This puts statement in a test method will produce output during test execution. Consider using a proper assertion or removing debug output.
puts "Verify mode failed as expected: #{e.message}"
test/integration/template_recorder_integration_test.rb:133
- This puts statement in a test method will produce output during test execution. Consider using a proper assertion or removing debug output.
puts "Files captured: #{data['file_system'].keys}" unless files_captured
performance/theme_runner.rb:29
- [nitpick] The parameter name 'strictness' is unclear - consider a more descriptive name like 'strict_options' or 'strict_filters'.
@strictness = strictness
| recorded_version = @data['engine']['liquid_version'] | ||
| current_version = Liquid::VERSION |
There was a problem hiding this comment.
Version comparison using string equality may fail for semantic versioning. Consider using proper version comparison logic.
| recorded_version = @data['engine']['liquid_version'] | |
| current_version = Liquid::VERSION | |
| recorded_version = Gem::Version.new(@data['engine']['liquid_version']) | |
| current_version = Gem::Version.new(Liquid::VERSION) |
| end | ||
| result | ||
| end | ||
|
|
There was a problem hiding this comment.
The smart_merge method performs deep merging which could be expensive for large data structures. Consider optimizing for common cases or adding size limits.
| # Calculate the size of a nested data structure | |
| def calculate_size(data) | |
| case data | |
| when Hash | |
| data.sum { |_, v| calculate_size(v) } + data.size | |
| when Array | |
| data.sum { |v| calculate_size(v) } + data.size | |
| else | |
| 1 | |
| end | |
| end |
| # @param obj [Object] Object to sanitize | ||
| # @param visited [Set] Set of visited object IDs to prevent infinite recursion | ||
| # @return [Object] Serializable version of object | ||
| def self.ensure_serializable(obj, visited = Set.new) |
There was a problem hiding this comment.
Creating a new Set for each call may be inefficient for deeply nested structures. Consider reusing the visited set or using a different circular reference detection approach.
| def self.ensure_serializable(obj, visited = Set.new) | |
| def self.ensure_serializable(obj, visited = nil) | |
| visited ||= Set.new |
| # | ||
| # @param path [String] Path like "product.variants[0].name" | ||
| # @return [Array<Hash>] Array of path components | ||
| def parse_path(path) |
There was a problem hiding this comment.
The parse_path method is complex with multiple state variables and nested conditions. Consider breaking it into smaller methods or using a more structured parsing approach.
|
:)))))))))))))))) |
|
This is really cool. I spent some time integrating this into our storefront to test it out, and here are some things that came up: 1. Multi-template architectures 2. Template parsing flow variations template = Liquid::Template.new
template.parse(source, options)The instance method interception works, but the RecordingTemplate wrapper approach might need some adjustments for this pattern. 3. Section rendering context 4. File path resolution 5. Template source capture timing The JSON schema is well structured and the recorder's approach to tracking variable access and filter calls is solid. Would be happy to discuss extending this to handle multi-template scenarios if you're interested. |
548996e to
3ef4f2a
Compare
Summary
Adds hermetic recording and deterministic replay for successful Liquid renders.
What is captured
Storage
.json: an atomic session file containing every render in the block.jsonl: append-only, one compact and independently replayable render per lineindex:(the last record is the default)JSONL is deliberately the canonical production-sampling format rather than one large pretty JSON object. Compression is kept orthogonal: rotated JSONL can be compressed externally, and a future
.jsonl.zstwriter can use one independent zstd frame per record without changing the schema. A single long-lived compressed stream would make append/recovery/selection worse.Replay modes
compute: run available filters normallystrict: return exact recorded filter results and reject a changed call sequenceverify: compute normally and reject output differencesDesign changes from the previous version
This is a ground-up simplification after reviewing the old implementation. It removes the global
Template.parsemonkey patch, proxy Hash/Array values, guessed Drop ivar extraction, lossy filter summaries, broken custom FileSystem keyword change, duplicated render wrappers, benchmark-specific CLI, and unrelated performance changes. Recording state is thread-local, nested sessions are rejected, JSON writes are atomic, and failures never delete a pre-existing destination.Test plan
bundle exec rake test: all lax/strict/strict2 unit and integration runs passliquid-specbranch pin (the upstream PR is merged) and two current-main strict2/RuboCop test regressions so CI can install and runSee
docs/template_recorder.mdfor the API and format tradeoffs.