mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
Add cumulative resource score tracking across partial renders (#2058)
* feat: add cumulative resource score tracking across partial renders Add cumulative_render_score and cumulative_assign_score counters to ResourceLimits that accumulate across reset() calls, with optional cumulative_render_score_limit and cumulative_assign_score_limit to cap total work across all partial renders. Also add a reached? check in BlockBody's render loop so that once a cumulative limit triggers, the parent template stops processing further nodes. Bump version to 5.12.0. * refactor: move cumulative limit enforcement into reset() Instead of checking reached? in BlockBody's render loop, enforce cumulative limits in reset() itself. Since reset() is called before the begin/rescue MemoryError block in Template#render, the raise propagates to the parent naturally — no changes to BlockBody needed.
This commit is contained in:
@@ -2,24 +2,39 @@
|
|||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
class ResourceLimits
|
class ResourceLimits
|
||||||
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
|
attr_accessor :render_length_limit,
|
||||||
attr_reader :render_score, :assign_score
|
:render_score_limit,
|
||||||
|
:assign_score_limit,
|
||||||
|
:cumulative_render_score_limit,
|
||||||
|
:cumulative_assign_score_limit
|
||||||
|
attr_reader :render_score,
|
||||||
|
:assign_score,
|
||||||
|
:cumulative_render_score,
|
||||||
|
:cumulative_assign_score
|
||||||
|
|
||||||
def initialize(limits)
|
def initialize(limits)
|
||||||
@render_length_limit = limits[:render_length_limit]
|
@render_length_limit = limits[:render_length_limit]
|
||||||
@render_score_limit = limits[:render_score_limit]
|
@render_score_limit = limits[:render_score_limit]
|
||||||
@assign_score_limit = limits[:assign_score_limit]
|
@assign_score_limit = limits[:assign_score_limit]
|
||||||
|
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
|
||||||
|
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
|
||||||
|
@cumulative_render_score = 0
|
||||||
|
@cumulative_assign_score = 0
|
||||||
reset
|
reset
|
||||||
end
|
end
|
||||||
|
|
||||||
def increment_render_score(amount)
|
def increment_render_score(amount)
|
||||||
@render_score += amount
|
@render_score += amount
|
||||||
|
@cumulative_render_score += amount
|
||||||
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
|
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
|
||||||
|
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||||
end
|
end
|
||||||
|
|
||||||
def increment_assign_score(amount)
|
def increment_assign_score(amount)
|
||||||
@assign_score += amount
|
@assign_score += amount
|
||||||
|
@cumulative_assign_score += amount
|
||||||
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
|
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
|
||||||
|
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||||
end
|
end
|
||||||
|
|
||||||
# update either render_length or assign_score based on whether or not the writes are captured
|
# update either render_length or assign_score based on whether or not the writes are captured
|
||||||
@@ -47,6 +62,8 @@ module Liquid
|
|||||||
@reached_limit = false
|
@reached_limit = false
|
||||||
@last_capture_length = nil
|
@last_capture_length = nil
|
||||||
@render_score = @assign_score = 0
|
@render_score = @assign_score = 0
|
||||||
|
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||||
|
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_capture
|
def with_capture
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
VERSION = "5.11.0"
|
VERSION = "5.12.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -179,6 +179,86 @@ class TemplateTest < Minitest::Test
|
|||||||
assert_equal("すごい", t.render)
|
assert_equal("すごい", t.render)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_cumulative_render_score_limit_across_render_tags
|
||||||
|
file_system = StubFileSystem.new(
|
||||||
|
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||||
|
)
|
||||||
|
environment = Liquid::Environment.build(file_system: file_system)
|
||||||
|
|
||||||
|
# Without cumulative limit, all 5 partials render successfully
|
||||||
|
t = Template.parse(
|
||||||
|
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
unlimited_output = t.render!
|
||||||
|
total_cumulative = t.resource_limits.cumulative_render_score
|
||||||
|
|
||||||
|
# With cumulative limit set below the total, rendering stops early
|
||||||
|
t2 = Template.parse(
|
||||||
|
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
|
||||||
|
limited_output = t2.render
|
||||||
|
assert(t2.resource_limits.reached?)
|
||||||
|
assert_operator(limited_output.length, :<, unlimited_output.length)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_render_score_limit_raises_on_render_bang
|
||||||
|
file_system = StubFileSystem.new(
|
||||||
|
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||||
|
)
|
||||||
|
environment = Liquid::Environment.build(file_system: file_system)
|
||||||
|
t = Template.parse(
|
||||||
|
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
t.resource_limits.cumulative_render_score_limit = 20
|
||||||
|
assert_raises(Liquid::MemoryError) do
|
||||||
|
t.render!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_assign_score_limit_across_include_tags
|
||||||
|
file_system = StubFileSystem.new(
|
||||||
|
'assign_partial' => '{% assign x = "a long string value here" %}',
|
||||||
|
)
|
||||||
|
environment = Liquid::Environment.build(file_system: file_system)
|
||||||
|
|
||||||
|
# Without cumulative limit, all 5 partials render
|
||||||
|
t = Template.parse(
|
||||||
|
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
t.render!
|
||||||
|
total_cumulative = t.resource_limits.cumulative_assign_score
|
||||||
|
|
||||||
|
# With cumulative limit set below the total, rendering stops early
|
||||||
|
t2 = Template.parse(
|
||||||
|
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
|
||||||
|
t2.render
|
||||||
|
assert(t2.resource_limits.reached?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_render_score_tracks_across_partials_without_limit
|
||||||
|
file_system = StubFileSystem.new(
|
||||||
|
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||||
|
)
|
||||||
|
environment = Liquid::Environment.build(file_system: file_system)
|
||||||
|
t = Template.parse(
|
||||||
|
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||||
|
environment: environment,
|
||||||
|
)
|
||||||
|
t.render!
|
||||||
|
assert(
|
||||||
|
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
|
||||||
|
"cumulative should exceed per-template score after multiple partials",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def test_default_resource_limits_unaffected_by_render_with_context
|
def test_default_resource_limits_unaffected_by_render_with_context
|
||||||
context = Context.new
|
context = Context.new
|
||||||
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
|
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'test_helper'
|
||||||
|
|
||||||
|
class ResourceLimitsUnitTest < Minitest::Test
|
||||||
|
def test_cumulative_scores_initialize_to_zero
|
||||||
|
limits = Liquid::ResourceLimits.new({})
|
||||||
|
assert_equal(0, limits.cumulative_render_score)
|
||||||
|
assert_equal(0, limits.cumulative_assign_score)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_limits_default_to_nil
|
||||||
|
limits = Liquid::ResourceLimits.new({})
|
||||||
|
assert_nil(limits.cumulative_render_score_limit)
|
||||||
|
assert_nil(limits.cumulative_assign_score_limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_limits_configurable_via_hash
|
||||||
|
limits = Liquid::ResourceLimits.new(
|
||||||
|
cumulative_render_score_limit: 500,
|
||||||
|
cumulative_assign_score_limit: 300,
|
||||||
|
)
|
||||||
|
assert_equal(500, limits.cumulative_render_score_limit)
|
||||||
|
assert_equal(300, limits.cumulative_assign_score_limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_limits_configurable_via_accessor
|
||||||
|
limits = Liquid::ResourceLimits.new({})
|
||||||
|
limits.cumulative_render_score_limit = 500
|
||||||
|
assert_equal(500, limits.cumulative_render_score_limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_scores_survive_reset
|
||||||
|
limits = Liquid::ResourceLimits.new({})
|
||||||
|
limits.increment_render_score(10)
|
||||||
|
limits.increment_assign_score(5)
|
||||||
|
|
||||||
|
limits.reset
|
||||||
|
|
||||||
|
assert_equal(0, limits.render_score)
|
||||||
|
assert_equal(0, limits.assign_score)
|
||||||
|
assert_equal(10, limits.cumulative_render_score)
|
||||||
|
assert_equal(5, limits.cumulative_assign_score)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_scores_accumulate_across_resets
|
||||||
|
limits = Liquid::ResourceLimits.new({})
|
||||||
|
limits.increment_render_score(10)
|
||||||
|
limits.reset
|
||||||
|
limits.increment_render_score(20)
|
||||||
|
limits.reset
|
||||||
|
limits.increment_render_score(30)
|
||||||
|
|
||||||
|
assert_equal(30, limits.render_score)
|
||||||
|
assert_equal(60, limits.cumulative_render_score)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_render_score_limit_raises
|
||||||
|
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
|
||||||
|
limits.increment_render_score(10)
|
||||||
|
limits.reset
|
||||||
|
limits.increment_render_score(10)
|
||||||
|
limits.reset
|
||||||
|
|
||||||
|
assert_raises(Liquid::MemoryError) do
|
||||||
|
limits.increment_render_score(10)
|
||||||
|
end
|
||||||
|
assert(limits.reached?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cumulative_assign_score_limit_raises
|
||||||
|
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
|
||||||
|
limits.increment_assign_score(8)
|
||||||
|
limits.reset
|
||||||
|
|
||||||
|
assert_raises(Liquid::MemoryError) do
|
||||||
|
limits.increment_assign_score(8)
|
||||||
|
end
|
||||||
|
assert(limits.reached?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_per_template_limits_still_work_with_cumulative
|
||||||
|
limits = Liquid::ResourceLimits.new(
|
||||||
|
render_score_limit: 50,
|
||||||
|
cumulative_render_score_limit: 1000,
|
||||||
|
)
|
||||||
|
assert_raises(Liquid::MemoryError) do
|
||||||
|
limits.increment_render_score(51)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user