Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
49970a8
Serde plan foundation: shared per-shape plan cache
rohangavankar Sep 17, 2026
db69452
chore(serde): Add JSON serde spec and before/after benchmark
rohangavankar Sep 21, 2026
d8d4d80
perf(serde): Compile JSON encode plans per shape
rohangavankar Sep 21, 2026
39a0737
perf(serde): Compile JSON decode plans per shape
rohangavankar Sep 21, 2026
e6b46f9
perf(serde): Add x86 merge-grade JSON encode benchmarks
rohangavankar Sep 21, 2026
3ec175c
perf(serde): Add x86 merge-grade JSON decode benchmarks
rohangavankar Sep 21, 2026
6d105fe
docs(serde): Mark JSON encode and decode steps complete with x86 results
rohangavankar Sep 21, 2026
ea2a691
docs(serde): Add JSON serde phase summary with x86 results
rohangavankar Sep 21, 2026
e3b48c3
Merge branch 'aws:master' into serde-cache
rohangavankar Sep 21, 2026
fe46ffe
perf(serde): Add memory, large-payload, and full-corpus x86 benchmarks
rohangavankar Sep 22, 2026
ef39d55
Merge remote-tracking branch 'origin/serde-cache' into serde-cache
rohangavankar Sep 22, 2026
456264b
chore(serde): Keep benchmark results out of the public SDK
rohangavankar Sep 22, 2026
c0ccc80
test(serde): Add JSON plan compilation and invalidation unit tests
rohangavankar Sep 22, 2026
c041f9a
test(serde): Cover root timestamp decode plan compilation
rohangavankar Sep 22, 2026
3f83880
test(serde): Cover offsetUnset and list/map resolved-child clearing
rohangavankar Sep 22, 2026
57d363d
perf(serde): Compile XML encode plans per shape
rohangavankar Sep 24, 2026
729a74c
perf(serde): Compile XML decode plans per shape
rohangavankar Sep 24, 2026
e5ab968
refactor(serde): Rename plan cache accessors to match architecture.md
rohangavankar Sep 25, 2026
c285853
docs(serde): Restore explanatory comments in XmlParser legacy path
rohangavankar Sep 25, 2026
a727224
perf(serde): Compile XML root element name into the encode plan
rohangavankar Sep 25, 2026
919e342
chore(serde): Exclude benchmark/ from the published package
rohangavankar Sep 25, 2026
2b0d224
Merge remote-tracking branch 'upstream/master' into serde-cache
rohangavankar Sep 29, 2026
1f4413d
chore: Remove .kiro spec dir from PR (local tooling artifact)
rohangavankar Sep 29, 2026
cac0a80
chore: Remove benchmark harness from PR (keep off public repo)
rohangavankar Sep 29, 2026
3ec3919
style(serde): Apply PHPCS autofixes to touched files
rohangavankar Sep 30, 2026
4f484b8
style(serde): Suppress camel-case sniff on legacy dispatch methods
rohangavankar Sep 30, 2026
83b652c
fix(serde): Keep null list elements null in JSON decode
rohangavankar Oct 1, 2026
bdd80cf
fix(serde): Write only string xmlAttribute members as attributes
rohangavankar Oct 1, 2026
6f4956d
fix(serde): Refresh operation context params on model change
rohangavankar Oct 1, 2026
f9aa409
style(serde): Keep first condition on the if line
rohangavankar Oct 1, 2026
ce903a9
refactor(serde): Remove legacy parse and build paths
rohangavankar Oct 1, 2026
e12b87e
fix(serde): Resolve XML root name only for the root shape
rohangavankar Oct 5, 2026
4791638
chore: Drop leftover benchmark ignore entries
rohangavankar Oct 5, 2026
fbf61a6
fix(serde): Rebuild operation context params after shape mutation
rohangavankar Oct 5, 2026
701c316
style(serde): Fix PHPCS findings in serde parsers and plans
rohangavankar Oct 5, 2026
c1eee3e
fix(serde): Read live contextParam and guard XML attribute key
rohangavankar Oct 7, 2026
e895ae7
chore: Add changelog entry for serde plan cache
rohangavankar Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/nextrelease/serde-plan-cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"type": "enhancement",
"category": "Api",
"description": "Caches compiled JSON and XML serialization and parsing plans on model shapes, reducing per-request serialization and parsing overhead. Wire output and parsed results are unchanged, and plans are invalidated when a shape is mutated."
}
]
98 changes: 98 additions & 0 deletions src/Api/AbstractModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ abstract class AbstractModel implements \ArrayAccess
/** @var array */
protected $contextParam;

/** @var array Cached serde plans keyed by ShapePlanCache slot. */
protected $cachedPlans = [];

/** @var int Graph generation the cached plans were built against. */
protected $planGeneration = 0;

/**
* @param array $definition Service description
* @param ShapeMap $shapeMap Shapemap used for creating shapes
Expand All @@ -28,6 +34,43 @@ public function __construct(array $definition, ShapeMap $shapeMap)
}
}

/**
* Get a cached serde plan for the given slot.
*
* Returns null when no plan is cached, or when the cache is stale because
* a related model object mutated since the plan was built.
*
* @param int $slot A ShapePlanCache slot constant.
*
* @return mixed|null
* @internal
*/
public function getSerdePlan($slot)
{
$this->syncPlanGeneration();

return $this->cachedPlans[$slot] ?? null;
}

/**
* Cache a serde plan for the given slot and return it.
*
* Returns the plan so providers can compile and cache in one expression.
*
* @param int $slot A ShapePlanCache slot constant.
* @param mixed $plan The compiled plan to cache.
*
* @return mixed The cached plan.
* @internal
*/
public function cacheSerdePlan($slot, $plan)
{
$this->syncPlanGeneration();
$this->cachedPlans[$slot] = $plan;

return $plan;
}

public function toArray()
{
return $this->definition;
Expand All @@ -50,6 +93,7 @@ public function offsetGet($offset)
public function offsetSet($offset, $value)
{
$this->definition[$offset] = $value;
$this->invalidateResolvedModel();
}

/**
Expand All @@ -68,6 +112,60 @@ public function offsetExists($offset)
public function offsetUnset($offset)
{
unset($this->definition[$offset]);
$this->invalidateResolvedModel();
}

/**
* Clear resolved child model objects cached on this instance.
*
* The base model caches no resolved children. Subclasses that memoize
* resolved members, elements, or shapes override this to drop them so a
* definition change is reflected on the next access.
*
* @return void
*/
protected function clearResolvedModelCache()
{
// No resolved children on the base model.
}

/**
* Drop resolved children and cached plans after a definition mutation, then
* advance the graph generation so plans derived on related objects rebuild.
*
* Safe when no ShapeMap is present, which happens for mocks that construct
* model objects directly.
*
* @return void
*/
private function invalidateResolvedModel()
{
$this->clearResolvedModelCache();
$this->cachedPlans = [];

if ($this->shapeMap !== null) {
$this->shapeMap->incrementGeneration();
$this->planGeneration = $this->shapeMap->getGeneration();
}
}

/**
* Drop cached plans when the graph generation has moved past the one they
* were built against.
*
* @return void
*/
private function syncPlanGeneration()
{
if ($this->shapeMap === null) {
return;
}

$generation = $this->shapeMap->getGeneration();
if ($this->planGeneration !== $generation) {
$this->cachedPlans = [];
$this->planGeneration = $generation;
}
}

protected function shapeAt($key)
Expand Down
8 changes: 8 additions & 0 deletions src/Api/ListShape.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,12 @@ public function getMember()

return $this->member;
}

/**
* @return void
*/
protected function clearResolvedModelCache()
{
$this->member = null;
}
}
9 changes: 9 additions & 0 deletions src/Api/MapShape.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,13 @@ public function getKey()

return $this->key;
}

/**
* @return void
*/
protected function clearResolvedModelCache()
{
$this->value = null;
$this->key = null;
}
}
28 changes: 27 additions & 1 deletion src/Api/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Operation extends AbstractModel
private $errors;
private $staticContextParams = [];
private $contextParams;
private $contextParamsGeneration;
private $operationContextParams = [];

public function __construct(array $definition, ShapeMap $shapeMap)
Expand All @@ -35,6 +36,7 @@ public function __construct(array $definition, ShapeMap $shapeMap)

parent::__construct($definition, $shapeMap);
$this->contextParams = $this->setContextParams();
$this->contextParamsGeneration = $shapeMap->getGeneration();
}

/**
Expand Down Expand Up @@ -126,6 +128,14 @@ public function getStaticContextParams()
*/
public function getContextParams()
{
// Context params derive from the input shape graph, so rebuild them
// when any shape in the shared ShapeMap has been mutated.
$generation = $this->shapeMap->getGeneration();
if ($this->contextParams === null || $this->contextParamsGeneration !== $generation) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new generation check rebuilds context parameters, but getContextParam() still returns the value copied during construction. Directly mutating or removing a member’s contextParam increments the generation but returns the old context parameter. Read from the mutable definition or refresh the cached property, and test direct mutation/removal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1eee3e. getContextParam() now reads the live definition, so mutating or removing a member's contextParam is reflected in getContextParam() and getContextParams(). The protected $contextParam property is still set in the constructor for subclasses. Test: testContextParamsFollowMemberContextParamMutation covers mutation and removal.

$this->contextParams = $this->setContextParams();
$this->contextParamsGeneration = $generation;
}

return $this->contextParams;
}

Expand All @@ -140,12 +150,28 @@ public function getOperationContextParams(): array
return $this->operationContextParams;
}

/**
* @return void
*/
protected function clearResolvedModelCache()
Comment thread
stobrien89 marked this conversation as resolved.
{
$this->input = null;
$this->output = null;
$this->errors = null;

// Context params derive from the definition and the input shape, so
// refresh them too. Dynamic context params rebuild on next access.
$this->staticContextParams = $this->definition['staticContextParams'] ?? [];
$this->operationContextParams = $this->definition['operationContextParams'] ?? [];
$this->contextParams = null;
}

private function setContextParams()
{
$members = $this->getInput()->getMembers();
$contextParams = [];

foreach($members as $name => $shape) {
foreach ($members as $name => $shape) {
if (!empty($contextParam = $shape->getContextParam())) {
$contextParams[$contextParam['name']] = [
'shape' => $name,
Expand Down
104 changes: 76 additions & 28 deletions src/Api/Parser/JsonParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,121 @@
namespace Aws\Api\Parser;

use Aws\Api\DateTimeResult;
use Aws\Api\Serde\Json\JsonDecodePlan;
use Aws\Api\Serde\Json\JsonDecodePlanProvider;
use Aws\Api\Serde\Json\JsonShapeType;
use Aws\Api\Shape;

/**
* @internal Implements standard JSON parsing.
*/
class JsonParser
{
/** @var JsonDecodePlanProvider */
private $planProvider;

public function __construct()
{
$this->planProvider = new JsonDecodePlanProvider();
}

public function parse(Shape $shape, $value)
{
if ($value === null) {
return $value;
}

switch ($shape['type']) {
case 'structure':
if (isset($shape['document']) && $shape['document']) {
return $value;
}
return $this->parsePlan($this->planProvider->get($shape), $value);
}

/**
* Decodes a value using a compiled plan instead of re-reading the model.
*
* Preserves modeled member order and union handling.
*/
private function parsePlan(JsonDecodePlan $plan, $value)
{
switch ($plan->type) {
case JsonShapeType::STRUCTURE:
$target = [];
foreach ($shape->getMembers() as $name => $member) {
$locationName = $member['locationName'] ?: $name;
if (isset($value[$locationName])) {
$target[$name] = $this->parse($member, $value[$locationName]);
foreach ($plan->members as $member) {
$wire = $member[JsonDecodePlan::M_WIRE];
if (isset($value[$wire])) {
$target[$member[JsonDecodePlan::M_SDK]] = $this->parseByType(
$member[JsonDecodePlan::M_TYPE],
$member[JsonDecodePlan::M_SHAPE],
$member[JsonDecodePlan::M_TSFORMAT],
$value[$wire]
);
}
}
if (isset($shape['union'])
&& $shape['union']
&& is_array($value)
&& empty($target)
) {
if ($plan->union && is_array($value) && empty($target)) {
foreach ($value as $key => $val) {
$target['Unknown'][$key] = $val;
}
}
return $target;

case 'list':
$member = $shape->getMember();
case JsonShapeType::LIST:
$type = $plan->value[JsonDecodePlan::V_TYPE];
$shape = $plan->value[JsonDecodePlan::V_SHAPE];
$ts = $plan->value[JsonDecodePlan::V_TSFORMAT];
$target = [];
foreach ($value as $v) {
$target[] = $this->parse($member, $v);
$target[] = $this->parseByType($type, $shape, $ts, $v);
Comment thread
stobrien89 marked this conversation as resolved.
}
return $target;

case 'map':
$values = $shape->getValue();
case JsonShapeType::MAP:
$type = $plan->value[JsonDecodePlan::V_TYPE];
$shape = $plan->value[JsonDecodePlan::V_SHAPE];
$ts = $plan->value[JsonDecodePlan::V_TSFORMAT];
$target = [];
foreach ($value as $k => $v) {
// null map values should not be deserialized
if (!is_null($v)) {
$target[$k] = $this->parse($values, $v);
$target[$k] = $this->parseByType($type, $shape, $ts, $v);
}
}
return $target;

case 'timestamp':
return DateTimeResult::fromTimestamp(
$value,
!empty($shape['timestampFormat']) ? $shape['timestampFormat'] : null
);
case JsonShapeType::TIMESTAMP:
return DateTimeResult::fromTimestamp($value, $plan->timestampFormat);

case 'blob':
case JsonShapeType::BLOB:
return base64_decode($value);

default:
default: // SCALAR, DOCUMENT
return $value;
}
}
}

/**
* Decodes one member or collection element. Composite children fetch their
* own plan lazily; leaf types are handled inline.
*/
private function parseByType(int $type, Shape $shape, ?string $tsFormat, $value)
{
// A null value is returned as-is for every shape type, so sparse
// list elements stay null.
if ($value === null) {
return null;
}

switch ($type) {
case JsonShapeType::STRUCTURE:
case JsonShapeType::LIST:
case JsonShapeType::MAP:
return $this->parsePlan($this->planProvider->get($shape), $value);

case JsonShapeType::TIMESTAMP:
return DateTimeResult::fromTimestamp($value, $tsFormat);

case JsonShapeType::BLOB:
return base64_decode($value);

default: // SCALAR, DOCUMENT
return $value;
}
}
}
Loading
Loading