Skip to content

Commit a4ce599

Browse files
Merge pull request #103 from keyqcloud/fix/cf-invalidation-caller-reference-201
Fix CloudFront invalidation CallerReference collision (KYTE-#201)
2 parents bd2cbe2 + f211651 commit a4ce599

8 files changed

Lines changed: 221 additions & 150 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 4.11.1
2+
3+
### Fix: CloudFront invalidation `CallerReference` collision (direct/`KYTE_USE_SNS=false` path) — KYTE-#201
4+
5+
`Kyte\Aws\CloudFront::createInvalidation()` built its `CallerReference` as `time().$distributionId`**second precision**. Any two invalidations against the same distribution within the same second reused that reference with a different path batch, which CloudFront rejects with `InvalidArgument`. The wrapper then swallowed it as a generic *"Unable to create new invalidation"*. This made the **direct** invalidation path (`KYTE_USE_SNS=false`) flaky under rapid or bulk publishes — a/the reason invalidation was historically routed through SNS→Lambda instead.
6+
7+
Fix: append `uniqid('', true)` (microsecond entropy) so successive CallerReferences never collide, and surface the real AWS error message instead of the generic one. Measured latency of a single `createInvalidation` is ~150–190 ms, so the synchronous direct call is well within request budgets (the old "timeout" symptom was this failure, not the call duration). Unblocks the #201 move off SNS for cache invalidation: fix lands first, then installs flip `KYTE_USE_SNS=false`, then the dead SNS branches get removed.
8+
9+
**Best-effort invalidation hardening.** With `KYTE_USE_SNS=false` the invalidation runs synchronously inside the publish request, so a transient CloudFront error (throttling, a missing distribution) would otherwise fail a publish whose content already wrote to S3 successfully. Wrapped all 10 invalidation sites (`KytePageController` ×3, `KyteScriptController` ×2, `KyteLibraryController` ×2, `KytePageDataController`, `NavigationController`, `SideNavController`) in best-effort try/catch — log and continue, never fail the publish — matching the pattern `ApplicationController` already used.
10+
111
## 4.11.0
212

313
### Feature: JWT-mode anonymous/public API access (AppContextStrategy) — KYTE-#229

src/Aws/CloudFront.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,17 +221,25 @@ public function createInvalidation($distributionId = null, $paths = ['/*']) {
221221

222222
$result = $this->client->createInvalidation([
223223
'DistributionId' => $distributionId,
224+
// CallerReference must be unique per call. time().$distributionId
225+
// (second precision) collided whenever two invalidations hit the
226+
// same distribution within the same second — CloudFront rejects a
227+
// reused CallerReference that carries a different batch with
228+
// InvalidArgument. That broke rapid/bulk publishes (and was a/the
229+
// reason direct invalidation was abandoned for SNS). uniqid(more)
230+
// adds microsecond entropy so successive calls never collide.
224231
'InvalidationBatch' => [
225-
'CallerReference' => time().$distributionId,
232+
'CallerReference' => time().$distributionId.uniqid('', true),
226233
'Paths' => [
227234
'Items' => $paths,
228235
'Quantity' => count($paths),
229236
],
230237
],
231238
]);
232239
} catch(\Exception $e) {
233-
throw new \Exception("Unable to create new invalidation");
234-
return false;
240+
// Surface the real AWS error instead of swallowing it — the old
241+
// generic message hid the InvalidArgument collision above.
242+
throw new \Exception("Unable to create CloudFront invalidation: ".$e->getMessage());
235243
}
236244

237245
return true;

src/Mvc/Controller/KyteLibraryController.php

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -102,20 +102,25 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) {
102102

103103
// invalidate CF
104104
$invalidationPaths = ['/*'];
105-
if (KYTE_USE_SNS) {
106-
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
107-
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
108-
$sns->publish([
109-
'action' => 'cf_invalidate',
110-
'site_id' => $r['site']['id'],
111-
'cf_id' => $r['site']['cfDistributionId'],
112-
'cf_invalidation_paths' => $invalidationPaths,
113-
'caller_id' => time(),
114-
]);
115-
} else {
116-
// invalidate CF
117-
$cf = new \Kyte\Aws\CloudFront($credential);
118-
$cf->createInvalidation($r['site']['cfDistributionId'], $invalidationPaths);
105+
// Best-effort: content already in S3; a failed invalidation must not fail the request.
106+
try {
107+
if (KYTE_USE_SNS) {
108+
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
109+
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
110+
$sns->publish([
111+
'action' => 'cf_invalidate',
112+
'site_id' => $r['site']['id'],
113+
'cf_id' => $r['site']['cfDistributionId'],
114+
'cf_invalidation_paths' => $invalidationPaths,
115+
'caller_id' => time(),
116+
]);
117+
} else {
118+
// invalidate CF
119+
$cf = new \Kyte\Aws\CloudFront($credential);
120+
$cf->createInvalidation($r['site']['cfDistributionId'], $invalidationPaths);
121+
}
122+
} catch (\Throwable $e) {
123+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
119124
}
120125

121126
// Reset original value for next request
@@ -176,20 +181,25 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) {
176181

177182
// invalidate CF
178183
$invalidationPaths = ['/*'];
179-
if (KYTE_USE_SNS) {
180-
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
181-
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
182-
$sns->publish([
183-
'action' => 'cf_invalidate',
184-
'site_id' => $d['site']['id'],
185-
'cf_id' => $d['site']['cfDistributionId'],
186-
'cf_invalidation_paths' => $invalidationPaths,
187-
'caller_id' => time(),
188-
]);
189-
} else {
190-
// invalidate CF
191-
$cf = new \Kyte\Aws\CloudFront($credential);
192-
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
184+
// Best-effort: content already in S3; a failed invalidation must not fail the request.
185+
try {
186+
if (KYTE_USE_SNS) {
187+
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
188+
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
189+
$sns->publish([
190+
'action' => 'cf_invalidate',
191+
'site_id' => $d['site']['id'],
192+
'cf_id' => $d['site']['cfDistributionId'],
193+
'cf_invalidation_paths' => $invalidationPaths,
194+
'caller_id' => time(),
195+
]);
196+
} else {
197+
// invalidate CF
198+
$cf = new \Kyte\Aws\CloudFront($credential);
199+
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
200+
}
201+
} catch (\Throwable $e) {
202+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
193203
}
194204
break;
195205

src/Mvc/Controller/KytePageController.php

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,25 @@ public function hook_preprocess($method, &$r, &$o = null) {
2525
$s3->rename($o->s3key, $r['s3key']);
2626
// invalidate CF cache
2727
$invalidationPaths = ['/*'];
28-
if (KYTE_USE_SNS) {
29-
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
30-
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
31-
$sns->publish([
32-
'action' => 'cf_invalidate',
33-
'site_id' => $d['site']['id'],
34-
'cf_id' => $d['site']['cfDistributionId'],
35-
'cf_invalidation_paths' => $invalidationPaths,
36-
'caller_id' => time(),
37-
]);
38-
} else {
39-
// invalidate CF
40-
$cf = new \Kyte\Aws\CloudFront($credential);
41-
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
28+
// Best-effort: content already in S3; a failed invalidation must not fail the request.
29+
try {
30+
if (KYTE_USE_SNS) {
31+
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
32+
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
33+
$sns->publish([
34+
'action' => 'cf_invalidate',
35+
'site_id' => $d['site']['id'],
36+
'cf_id' => $d['site']['cfDistributionId'],
37+
'cf_invalidation_paths' => $invalidationPaths,
38+
'caller_id' => time(),
39+
]);
40+
} else {
41+
// invalidate CF
42+
$cf = new \Kyte\Aws\CloudFront($credential);
43+
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
44+
}
45+
} catch (\Throwable $e) {
46+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
4247
}
4348
}
4449
break;
@@ -228,20 +233,25 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) {
228233

229234
// invalidate CF
230235
$invalidationPaths = ['/*'];
231-
if (KYTE_USE_SNS) {
232-
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
233-
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
234-
$sns->publish([
235-
'action' => 'cf_invalidate',
236-
'site_id' => $d['site']['id'],
237-
'cf_id' => $d['site']['cfDistributionId'],
238-
'cf_invalidation_paths' => $invalidationPaths,
239-
'caller_id' => time(),
240-
]);
241-
} else {
242-
// invalidate CF
243-
$cf = new \Kyte\Aws\CloudFront($credential);
244-
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
236+
// Best-effort: content already in S3; a failed invalidation must not fail the request.
237+
try {
238+
if (KYTE_USE_SNS) {
239+
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
240+
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
241+
$sns->publish([
242+
'action' => 'cf_invalidate',
243+
'site_id' => $d['site']['id'],
244+
'cf_id' => $d['site']['cfDistributionId'],
245+
'cf_invalidation_paths' => $invalidationPaths,
246+
'caller_id' => time(),
247+
]);
248+
} else {
249+
// invalidate CF
250+
$cf = new \Kyte\Aws\CloudFront($credential);
251+
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
252+
}
253+
} catch (\Throwable $e) {
254+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
245255
}
246256
}
247257

@@ -452,21 +462,26 @@ public static function publishPage($pageObj, $params, &$responseData) {
452462
}
453463

454464
// Invalidate CloudFront cache
455-
if (KYTE_USE_SNS) {
456-
// Use SNS for asynchronous invalidation
457-
$snsCredential = new \Kyte\Aws\Credentials(SNS_REGION);
458-
$sns = new \Kyte\Aws\Sns($snsCredential, SNS_QUEUE_SITE_MANAGEMENT);
459-
$sns->publish([
460-
'action' => 'cf_invalidate',
461-
'site_id' => $responseData['site']['id'],
462-
'cf_id' => $responseData['site']['cfDistributionId'],
463-
'cf_invalidation_paths' => $invalidationPaths,
464-
'caller_id' => time(),
465-
]);
466-
} else {
467-
// Direct CloudFront invalidation
468-
$cf = new \Kyte\Aws\CloudFront($credential);
469-
$cf->createInvalidation($responseData['site']['cfDistributionId'], $invalidationPaths);
465+
// Best-effort: content already in S3; a failed invalidation must not fail the publish.
466+
try {
467+
if (KYTE_USE_SNS) {
468+
// Use SNS for asynchronous invalidation
469+
$snsCredential = new \Kyte\Aws\Credentials(SNS_REGION);
470+
$sns = new \Kyte\Aws\Sns($snsCredential, SNS_QUEUE_SITE_MANAGEMENT);
471+
$sns->publish([
472+
'action' => 'cf_invalidate',
473+
'site_id' => $responseData['site']['id'],
474+
'cf_id' => $responseData['site']['cfDistributionId'],
475+
'cf_invalidation_paths' => $invalidationPaths,
476+
'caller_id' => time(),
477+
]);
478+
} else {
479+
// Direct CloudFront invalidation
480+
$cf = new \Kyte\Aws\CloudFront($credential);
481+
$cf->createInvalidation($responseData['site']['cfDistributionId'], $invalidationPaths);
482+
}
483+
} catch (\Throwable $e) {
484+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
470485
}
471486

472487
// true on a successful page write, false if the S3 upload failed.

src/Mvc/Controller/KytePageDataController.php

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,25 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) {
8787

8888
// invalidate CF
8989
$invalidationPaths = ['/*'];
90-
if (KYTE_USE_SNS) {
91-
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
92-
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
93-
$sns->publish([
94-
'action' => 'cf_invalidate',
95-
'site_id' => $d['site']['id'],
96-
'cf_id' => $d['site']['cfDistributionId'],
97-
'cf_invalidation_paths' => $invalidationPaths,
98-
'caller_id' => time(),
99-
]);
100-
} else {
101-
// invalidate CF
102-
$cf = new \Kyte\Aws\CloudFront($credential);
103-
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
90+
// Best-effort: content already in S3; a failed invalidation must not fail the request.
91+
try {
92+
if (KYTE_USE_SNS) {
93+
$credential = new \Kyte\Aws\Credentials(SNS_REGION);
94+
$sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT);
95+
$sns->publish([
96+
'action' => 'cf_invalidate',
97+
'site_id' => $d['site']['id'],
98+
'cf_id' => $d['site']['cfDistributionId'],
99+
'cf_invalidation_paths' => $invalidationPaths,
100+
'caller_id' => time(),
101+
]);
102+
} else {
103+
// invalidate CF
104+
$cf = new \Kyte\Aws\CloudFront($credential);
105+
$cf->createInvalidation($d['site']['cfDistributionId'], $invalidationPaths);
106+
}
107+
} catch (\Throwable $e) {
108+
error_log("CloudFront invalidation failed (best-effort): " . $e->getMessage());
104109
}
105110
}
106111

0 commit comments

Comments
 (0)