Skip to content

Commit 26fb811

Browse files
committed
Add hermetic template recording and replay
1 parent 807d45a commit 26fb811

12 files changed

Lines changed: 811 additions & 21 deletions

Gemfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ group :test do
3232
end
3333

3434
group :spec do
35-
# Using feature branch until https://github.com/Shopify/liquid-spec/pull/144 is merged
36-
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'self-drop-env-lookup-specs'
35+
gem 'liquid-spec', github: 'Shopify/liquid-spec'
3736
gem 'activesupport', require: false
3837
end

docs/template_recorder.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Recording and replaying renders
2+
3+
`Liquid::TemplateRecorder` captures successful template renders so they can be
4+
replayed without the application's file system or Drop implementations.
5+
Recording does not wrap or replace assigns, so the recorded render has the same
6+
semantics as a normal render.
7+
8+
```ruby
9+
Liquid::TemplateRecorder.record("render.json") do
10+
template = Liquid::Template.parse(source)
11+
template.render!(assigns)
12+
end
13+
14+
replayer = Liquid::TemplateRecorder.replay_from("render.json", mode: :verify)
15+
replayer.render # raises if the output changed
16+
```
17+
18+
A recording contains the root template, every parsed partial, partial contents,
19+
plain Hash/Array values resolved by the template, properties actually read from `Liquid::Drop` objects,
20+
filter-call diagnostics, engine options, and the rendered output. Drop instance
21+
variables are never inspected. An unsupported Ruby object raises
22+
`Liquid::TemplateRecorder::SerializationError` rather than silently producing a
23+
recording that cannot be replayed.
24+
25+
## Storage formats
26+
27+
A `.json` destination is written atomically after the recording block succeeds.
28+
It contains a session with every render performed by the block.
29+
30+
A `.jsonl` destination is append-only. Each successful top-level render is one
31+
compact, self-contained JSON line. This is the recommended format for production
32+
sampling: a process failure can lose at most the render being written, writers
33+
are serialized with `flock`, and a recording can be replayed by index.
34+
35+
A destination may instead be any writer object responding to `write(record)`. The
36+
writer receives one self-contained recording Hash per successful render. Liquid
37+
does not own or close injected writers, so applications can publish records to
38+
Kafka, object storage, or another transport without coupling that transport to
39+
the recorder.
40+
41+
```ruby
42+
Liquid::TemplateRecorder.record(kafka_writer) do
43+
template.render!(assigns)
44+
end
45+
```
46+
47+
```ruby
48+
Liquid::TemplateRecorder.replay_from("renders.jsonl") # last render
49+
Liquid::TemplateRecorder.replay_from("renders.jsonl", index: 0) # first render
50+
Liquid::TemplateRecorder.records("renders.jsonl") # inspect all
51+
```
52+
53+
Compression is intentionally separate from the schema. In particular, one
54+
long-lived compressed stream makes appending, recovery, and selecting a render
55+
harder. Compress rotated `.jsonl` files with the storage system of your choice;
56+
a future compressed writer can use one independent frame per record without a
57+
schema change.
58+
59+
Recording sessions are thread-local. Nested sessions in the same thread are
60+
rejected. Existing application register names and the one-argument
61+
`FileSystem#read_template_file` API remain unchanged.
62+
63+
## Replay modes
64+
65+
* `:compute` runs filters normally. Pass application filters with
66+
`replayer.render(filters: MyFilters)`.
67+
* `:strict` returns each exact recorded filter result and rejects a changed
68+
filter sequence. This can replay application-specific or nondeterministic
69+
filters without loading their implementations.
70+
* `:verify` computes normally and raises when the final output differs.

lib/liquid.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,4 @@ module Liquid
9090
require 'liquid/usage'
9191
require 'liquid/registers'
9292
require 'liquid/template_factory'
93+
require "liquid/template_recorder"

lib/liquid/context.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ def find_variable(key, raise_on_not_found: true)
228228

229229
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
230230

231+
recorder = @registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
232+
recorder&.emit_variable_read(key, liquid_variable)
231233
liquid_variable
232234
end
233235

lib/liquid/drop.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ def liquid_method_missing(method)
3737

3838
# called by liquid to invoke a drop
3939
def invoke_drop(method_or_key)
40-
if self.class.invokable?(method_or_key)
40+
result = if self.class.invokable?(method_or_key)
4141
send(method_or_key)
4242
else
4343
liquid_method_missing(method_or_key)
4444
end
45+
46+
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
47+
recorder&.emit_drop_read(self, method_or_key, result)
48+
result
4549
end
4650

4751
def key?(_name)

lib/liquid/partial_cache.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ def self.load(template_name, context:, parse_context:)
1010

1111
file_system = context.registers[:file_system]
1212
source = file_system.read_template_file(template_name)
13+
recorder = context.registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
14+
recorder&.emit_file_read(template_name, source)
1315

1416
parse_context.partial = true
1517

lib/liquid/strainer_template.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,17 @@ def filter_methods
4848
end
4949

5050
def invoke(method, *args)
51-
if self.class.invokable?(method)
51+
result = if self.class.invokable?(method)
5252
send(method, *args)
5353
elsif @context.strict_filters
5454
raise Liquid::UndefinedFilter, "undefined filter #{method}"
5555
else
5656
args.first
5757
end
58+
59+
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
60+
recorder&.emit_filter_call(method, args.first, args.drop(1), result)
61+
result
5862
rescue ::ArgumentError => e
5963
raise Liquid::ArgumentError, e.message, e.backtrace
6064
end

lib/liquid/template.rb

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def parse(source, options = {})
105105

106106
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
107107
@root = Document.parse(tokenizer, parse_context)
108+
@template_recorder_source = source.dup.freeze if defined?(TemplateRecorder) && TemplateRecorder.current
108109
self
109110
end
110111

@@ -141,6 +142,8 @@ def errors
141142
def render(*args)
142143
return '' if @root.nil?
143144

145+
recording_session = TemplateRecorder.current if defined?(TemplateRecorder)
146+
recording_assigns = args.first
144147
context = case args.first
145148
when Liquid::Context
146149
c = args.shift
@@ -180,6 +183,13 @@ def render(*args)
180183
context.add_filters(args.pop)
181184
end
182185

186+
recording = recording_session&.begin_render(self, recording_assigns, context)
187+
if recording
188+
recorder_registers = context.registers.static
189+
previous_recorder = recorder_registers[TemplateRecorder::REGISTER_KEY]
190+
recorder_registers[TemplateRecorder::REGISTER_KEY] = recording
191+
end
192+
183193
# Retrying a render resets resource usage
184194
context.resource_limits.reset
185195

@@ -192,17 +202,31 @@ def render(*args)
192202
previous_error_mode = context.registers.static[:template_error_mode]
193203
context.registers.static[:template_error_mode] = @error_mode
194204

205+
rendered_output = nil
206+
render_succeeded = false
195207
begin
196208
# render the nodelist.
197-
@root.render_to_output_buffer(context, output || +'')
209+
rendered_output = @root.render_to_output_buffer(context, output || +'')
210+
render_succeeded = true
211+
rendered_output
198212
rescue Liquid::MemoryError => e
199-
context.handle_error(e)
213+
rendered_output = context.handle_error(e)
214+
render_succeeded = true
215+
rendered_output
200216
ensure
201217
if previous_error_mode
202218
context.registers.static[:template_error_mode] = previous_error_mode
203219
else
204220
context.registers.static.delete(:template_error_mode)
205221
end
222+
if recording
223+
if previous_recorder
224+
recorder_registers[TemplateRecorder::REGISTER_KEY] = previous_recorder
225+
else
226+
recorder_registers.delete(TemplateRecorder::REGISTER_KEY)
227+
end
228+
recording_session.finish_render(recording, rendered_output, context, success: render_succeeded)
229+
end
206230
@errors = context.errors
207231
end
208232
end

0 commit comments

Comments
 (0)