Skip to content

Commit 9909f2b

Browse files
authored
Merge pull request #5197 from StackStorm/api_endpoint_exclude_result_if_size_greater
Implement coditional execution "result" field retrieval for the "/v1/executions/<id>" (get one execution) API endpoint
2 parents 5babe56 + 9cda14f commit 9909f2b

File tree

6 files changed

+223
-1
lines changed

6 files changed

+223
-1
lines changed

CHANGELOG.rst

+12
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ Improvements
173173

174174
Contributed by @Kami.
175175

176+
* Add new ``?max_result_size`` query parameter filter to the ``GET /v1/executiond/<id>`` API
177+
endpoint.
178+
179+
This query parameter allows clients to implement conditional execution result retrieval and
180+
only retrieve the result field if it's smaller than the provided value.
181+
182+
This comes handy in the various client scenarios (such as st2web) where we don't display and
183+
render very large results directly since it allows to speed things up and decrease amount of
184+
data retrieved and parsed. (improvement) #5197
185+
186+
Contributed by @Kami.
187+
176188
Fixed
177189
~~~~~
178190

st2api/st2api/controllers/resource.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -299,16 +299,23 @@ def _get_one_by_id(
299299
exclude_fields=None,
300300
include_fields=None,
301301
from_model_kwargs=None,
302+
get_by_id_kwargs=None,
302303
):
303304
"""
304305
:param exclude_fields: A list of object fields to exclude.
305306
:type exclude_fields: ``list``
306307
:param include_fields: A list of object fields to include.
307308
:type include_fields: ``list``
309+
:param get_by_id_kwargs: Additional keyword arguments which are passed to the
310+
"_get_by_id()" method.
311+
:type get_by_id_kwargs: ``dict`` or ``None``
308312
"""
309313

310314
instance = self._get_by_id(
311-
resource_id=id, exclude_fields=exclude_fields, include_fields=include_fields
315+
resource_id=id,
316+
exclude_fields=exclude_fields,
317+
include_fields=include_fields,
318+
**get_by_id_kwargs or {},
312319
)
313320

314321
if permission_type:

st2api/st2api/controllers/v1/actionexecutions.py

+97
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
from typing import Optional
17+
1618
import copy
1719
import re
1820
import sys
@@ -24,6 +26,7 @@
2426
import jsonschema
2527
from oslo_config import cfg
2628
from six.moves import http_client
29+
from mongoengine.queryset.visitor import Q
2730

2831
from st2api.controllers.base import BaseRestControllerMixin
2932
from st2api.controllers.resource import ResourceController
@@ -728,6 +731,7 @@ def get_one(
728731
exclude_attributes=None,
729732
include_attributes=None,
730733
show_secrets=False,
734+
max_result_size=None,
731735
):
732736
"""
733737
Retrieve a single execution.
@@ -751,6 +755,10 @@ def get_one(
751755
)
752756
}
753757

758+
max_result_size = self._validate_max_result_size(
759+
max_result_size=max_result_size
760+
)
761+
754762
# Special case for id == "last"
755763
if id == "last":
756764
execution_db = (
@@ -769,6 +777,7 @@ def get_one(
769777
requester_user=requester_user,
770778
from_model_kwargs=from_model_kwargs,
771779
permission_type=PermissionType.EXECUTION_VIEW,
780+
get_by_id_kwargs={"max_result_size": max_result_size},
772781
)
773782

774783
def post(
@@ -980,6 +989,94 @@ def delete(self, id, requester_user, show_secrets=False):
980989
execution_db, mask_secrets=from_model_kwargs["mask_secrets"]
981990
)
982991

992+
def _validate_max_result_size(
993+
self, max_result_size: Optional[int]
994+
) -> Optional[int]:
995+
"""
996+
Validate value of the ?max_result_size query parameter (if provided).
997+
"""
998+
# Maximum limit for MongoDB collection document is 16 MB and the field itself can't be
999+
# larger than that obviously. And in reality due to the other fields, overhead, etc,
1000+
# 14 is the upper limit.
1001+
if not max_result_size:
1002+
return max_result_size
1003+
1004+
if max_result_size <= 0:
1005+
raise ValueError("max_result_size must be a positive number")
1006+
1007+
if max_result_size > 14 * 1024 * 1024:
1008+
raise ValueError(
1009+
"max_result_size query parameter must be smaller than 14 MB"
1010+
)
1011+
1012+
return max_result_size
1013+
1014+
def _get_by_id(
1015+
self,
1016+
resource_id,
1017+
exclude_fields=None,
1018+
include_fields=None,
1019+
max_result_size=None,
1020+
):
1021+
"""
1022+
Custom version of _get_by_id() which supports ?max_result_size pre-filtering and not
1023+
returning result field for executions which result size exceeds this threshold.
1024+
1025+
This functionality allows us to implement fast and efficient retrievals in st2web.
1026+
"""
1027+
exclude_fields = exclude_fields or []
1028+
include_fields = include_fields or []
1029+
1030+
if not max_result_size:
1031+
# If max_result_size is not provided we don't perform any prefiltering and directly
1032+
# call parent method
1033+
execution_db = super(ActionExecutionsController, self)._get_by_id(
1034+
resource_id=resource_id,
1035+
exclude_fields=exclude_fields,
1036+
include_fields=include_fields,
1037+
)
1038+
return execution_db
1039+
1040+
# Special query where we check if result size is smaller than pre-defined or that field
1041+
# doesn't not exist (old executions) and only return the result if the condition is met.
1042+
# This allows us to implement fast and efficient retrievals of executions on the client
1043+
# st2web side where we don't want to retrieve and display result directly for executions
1044+
# with large results
1045+
# Keep in mind that the query itself is very fast and adds almost no overhead for API
1046+
# operations which pass this query parameter because we first filter on the ID (indexed
1047+
# field) and perform projection query with two tiny fields (based on real life testing it
1048+
# takes less than 3 ms in most scenarios).
1049+
execution_db = self.access.get(
1050+
Q(id=resource_id)
1051+
& (Q(result_size__lte=max_result_size) | Q(result_size__not__exists=True)),
1052+
only_fields=["id", "result_size"],
1053+
)
1054+
1055+
# if result is empty, this means that execution either doesn't exist or the result is
1056+
# larger than threshold which means we don't want to retrieve and return result to
1057+
# the end user to we set exclude_fields accordingly
1058+
if not execution_db:
1059+
LOG.debug(
1060+
"Execution with id %s and result_size < %s not found. This means "
1061+
"execution with this ID doesn't exist or result_size exceeds the "
1062+
"threshold. Result field will be excluded from the retrieval and "
1063+
"the response." % (resource_id, max_result_size)
1064+
)
1065+
1066+
if include_fields and "result" in include_fields:
1067+
include_fields.remove("result")
1068+
elif not include_fields:
1069+
exclude_fields += ["result"]
1070+
1071+
# Now call parent get by id with potentially modified include / exclude fields in case
1072+
# result should not be included
1073+
execution_db = super(ActionExecutionsController, self)._get_by_id(
1074+
resource_id=resource_id,
1075+
exclude_fields=exclude_fields,
1076+
include_fields=include_fields,
1077+
)
1078+
return execution_db
1079+
9831080
def _get_action_executions(
9841081
self,
9851082
exclude_fields=None,

st2api/tests/unit/controllers/v1/test_executions.py

+98
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from st2common.util import crypto as crypto_utils
4646
from st2common.util import date as date_utils
4747
from st2common.util import isotime
48+
from st2common.util.jsonify import json_encode
4849
from st2api.controllers.v1.actionexecutions import ActionExecutionsController
4950
import st2common.validators.api.action as action_validator
5051
from st2tests.api import BaseActionExecutionControllerTestCase
@@ -351,6 +352,103 @@ def test_get_one(self):
351352
self.assertEqual(get_resp.status_int, 200)
352353
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
353354

355+
def test_get_one_max_result_size_query_parameter(self):
356+
data = copy.deepcopy(LIVE_ACTION_1)
357+
post_resp = self._do_post(LIVE_ACTION_1)
358+
359+
actionexecution_id = self._get_actionexecution_id(post_resp)
360+
361+
# Update it with the result (this populates result and result size attributes)
362+
data = {
363+
"result": {"fooo": "a" * 1000},
364+
"status": "succeeded",
365+
}
366+
actual_result_size = len(json_encode(data["result"]))
367+
368+
# NOTE: In real-life result_size is populdated in update_execution() method which is
369+
# called in the end with the actual result
370+
put_resp = self._do_put(actionexecution_id, data)
371+
self.assertEqual(put_resp.json["result_size"], actual_result_size)
372+
self.assertEqual(put_resp.json["result"], data["result"])
373+
374+
# 1. ?max_result_size query filter not provided
375+
get_resp = self._do_get_one(actionexecution_id)
376+
self.assertEqual(get_resp.status_int, 200)
377+
self.assertEqual(get_resp.json["result"], data["result"])
378+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
379+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
380+
381+
# 2. ?max_result_size > actual result size
382+
get_resp = self._do_get_one(
383+
actionexecution_id + "?max_result_size=%s" % (actual_result_size + 1)
384+
)
385+
self.assertEqual(get_resp.status_int, 200)
386+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
387+
self.assertEqual(get_resp.json["result"], data["result"])
388+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
389+
390+
# 3. ?max_result_size < actual result size - result field should not be returned
391+
get_resp = self._do_get_one(
392+
actionexecution_id + "?max_result_size=%s" % (actual_result_size - 1)
393+
)
394+
self.assertEqual(get_resp.status_int, 200)
395+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
396+
self.assertTrue("result" not in get_resp.json)
397+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
398+
399+
# 4. ?max_result_size < actual result size and ?include_attributes=result - result field
400+
# should not be returned
401+
get_resp = self._do_get_one(
402+
actionexecution_id
403+
+ "?include_attributes=result,result_size&max_result_size=%s"
404+
% (actual_result_size - 1)
405+
)
406+
self.assertEqual(get_resp.status_int, 200)
407+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
408+
self.assertTrue("result" not in get_resp.json)
409+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
410+
411+
# 5. ?max_result_size > actual result size and ?exclude_attributes=result - result field
412+
# should not be returned
413+
get_resp = self._do_get_one(
414+
actionexecution_id
415+
+ "?include_attributes=result_size&exclude_attriubtes=result&max_result_size=%s"
416+
% (actual_result_size - 1)
417+
)
418+
self.assertEqual(get_resp.status_int, 200)
419+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
420+
self.assertTrue("result" not in get_resp.json)
421+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
422+
423+
# 6. max_result_size is not a positive number
424+
get_resp = self._do_get_one(
425+
actionexecution_id + "?max_result_size=-100", expect_errors=True
426+
)
427+
self.assertEqual(get_resp.status_int, 400)
428+
self.assertEqual(
429+
get_resp.json["faultstring"], "max_result_size must be a positive number"
430+
)
431+
432+
# 7. max_result_size is > max possible value
433+
get_resp = self._do_get_one(
434+
actionexecution_id + "?max_result_size=%s" % ((14 * 1024 * 1024) + 1),
435+
expect_errors=True,
436+
)
437+
self.assertEqual(get_resp.status_int, 400)
438+
self.assertEqual(
439+
get_resp.json["faultstring"],
440+
"max_result_size query parameter must be smaller than 14 MB",
441+
)
442+
443+
# 8. ?max_result_size == actual result size - result should be returned
444+
get_resp = self._do_get_one(
445+
actionexecution_id + "?max_result_size=%s" % (actual_result_size)
446+
)
447+
self.assertEqual(get_resp.status_int, 200)
448+
self.assertEqual(get_resp.json["result_size"], actual_result_size)
449+
self.assertEqual(get_resp.json["result"], data["result"])
450+
self.assertEqual(self._get_actionexecution_id(get_resp), actionexecution_id)
451+
354452
def test_get_all_id_query_param_filtering_success(self):
355453
post_resp = self._do_post(LIVE_ACTION_1)
356454
actionexecution_id = self._get_actionexecution_id(post_resp)

st2common/st2common/openapi.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,10 @@ paths:
10951095
in: query
10961096
description: Show secrets in plain text
10971097
type: boolean
1098+
- name: max_result_size
1099+
in: query
1100+
type: integer
1101+
description: True to exclude result field from the response for executions which result field exceeds the provided size in bytes.
10981102
x-parameters:
10991103
- name: user
11001104
in: context

st2common/st2common/openapi.yaml.j2

+4
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,10 @@ paths:
10911091
in: query
10921092
description: Show secrets in plain text
10931093
type: boolean
1094+
- name: max_result_size
1095+
in: query
1096+
type: integer
1097+
description: True to exclude result field from the response for executions which result field exceeds the provided size in bytes.
10941098
x-parameters:
10951099
- name: user
10961100
in: context

0 commit comments

Comments
 (0)