Skip to content

Commit f27186b

Browse files
committed
Add cache to scale widget deployment
1 parent e0e5f86 commit f27186b

4 files changed

Lines changed: 132 additions & 14 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,23 @@ The application provides two main widget types:
203203
- Real-time goal tracking
204204
- Animated progress bars
205205

206+
3. **Card Widget** (`/widget-stream-card/{id}` / `/widget-event-card/{id}`)
207+
- Donation card with progress bar, amount and donor count
208+
209+
#### Cache & mise à jour des widgets
210+
211+
Les widgets se mettent à jour via un **polling AJAX toutes les 10 secondes** côté navigateur (appel aux endpoints `/fetch`).
212+
213+
Pour éviter de surcharger l'API HelloAsso, un **cache serveur avec TTL** est en place :
214+
215+
- Chaque widget stocke ses données en cache dans la colonne `cache_data` (JSON) et un horodatage `cache_updated_at` en base de données.
216+
- Lors d'un appel `/fetch`, le serveur vérifie d'abord si le cache est encore **frais** (âge < TTL). Si oui, les données en cache sont retournées directement **sans aucun appel à l'API HelloAsso**.
217+
- Si le cache est expiré, l'API HelloAsso est interrogée, puis le cache est mis à jour avec les nouvelles données et un nouveau timestamp.
218+
219+
**Le TTL est configurable** via la variable d'environnement `WIDGET_CACHE_TTL` (en secondes, défaut : **15 secondes**).
220+
221+
Cela garantit qu'**un seul appel API HelloAsso** est effectué par widget et par période de TTL, quel que soit le nombre de visiteurs simultanés.
222+
206223
### Database Migrations
207224
Database schema is managed through SQL migration files in the `migrations/` directory:
208225
- `00-init-db.sql`: Initial database setup
@@ -230,3 +247,4 @@ Key environment variables include:
230247
- Azure Blob Storage (BLOB_CONNECTION_STRING, BLOB_URL)
231248
- Email service (MANDRILL_API)
232249
- Application domain (WEBSITE_DOMAIN)
250+
- Widget cache TTL in seconds (WIDGET_CACHE_TTL, default: 15)

migrations/13-add-cache-ttl.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE {prefix}widget_alert_box ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
2+
ALTER TABLE {prefix}widget_donation_goal_bar ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
3+
ALTER TABLE {prefix}widget_card ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
4+

src/Controllers/WidgetController.php

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414

1515
class WidgetController
1616
{
17+
private int $cacheTtl;
18+
1719
public function __construct(
1820
private Twig $view,
1921
private ApiWrapper $apiWrapper,
2022
private FileManager $fileManager,
2123
private EventRepository $eventRepository,
2224
private StreamRepository $streamRepository,
2325
private WidgetRepository $widgetRepository,
24-
) {}
26+
) {
27+
$this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 15);
28+
}
2529

2630
// ── Helpers ───────────────────────────────────────────────────
2731

@@ -118,6 +122,15 @@ private function fetchStreamDonationData(string $streamGuid): array
118122
$cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream)
119123
?? ['amount' => 0, 'continuation_token' => ''];
120124

125+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
126+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
127+
return ['stream' => $charityStream, 'result' => [
128+
'amount' => $cacheData['amount'],
129+
'donations' => [],
130+
'continuation_token' => $cacheData['continuation_token'],
131+
]];
132+
}
133+
121134
$result = $this->apiWrapper->getAllOrders(
122135
$charityStream->organization_slug,
123136
$charityStream->form_slug,
@@ -131,6 +144,12 @@ private function fetchStreamDonationData(string $streamGuid): array
131144
'amount' => $result['amount'],
132145
'continuation_token' => $result['continuation_token'],
133146
]);
147+
} else {
148+
// Même si rien n'a changé, on met à jour le timestamp du cache
149+
$this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [
150+
'amount' => $cacheData['amount'],
151+
'continuation_token' => $cacheData['continuation_token'],
152+
]);
134153
}
135154

136155
return ['stream' => $charityStream, 'result' => $result];
@@ -148,12 +167,20 @@ private function fetchEventDonationData(string $eventGuid): array
148167
$cacheData = $this->widgetRepository->selectEventDonationWidgetCacheData($event)
149168
?? ['amount' => 0, 'streams' => []];
150169

170+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
171+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
172+
return ['event' => $event, 'cacheData' => $cacheData];
173+
}
174+
151175
$streams = $this->streamRepository->selectListByEvent($event);
152176
$oldAmount = $cacheData['amount'];
153177
$cacheData = $this->aggregateEventStreams($streams, $cacheData);
154178

155179
if ($oldAmount !== $cacheData['amount']) {
156180
$this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData);
181+
} else {
182+
// Même si rien n'a changé, on met à jour le timestamp du cache
183+
$this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData);
157184
}
158185

159186
return ['event' => $event, 'cacheData' => $cacheData];
@@ -171,6 +198,11 @@ private function fetchStreamCardData(string $streamGuid): array
171198
$cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream)
172199
?? ['amount' => 0, 'donors' => 0, 'continuation_token' => ''];
173200

201+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
202+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
203+
return ['stream' => $charityStream, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0];
204+
}
205+
174206
$result = $this->apiWrapper->getAllOrders(
175207
$charityStream->organization_slug,
176208
$charityStream->form_slug,
@@ -188,6 +220,13 @@ private function fetchStreamCardData(string $streamGuid): array
188220
'donors' => $donors,
189221
'continuation_token' => $result['continuation_token'],
190222
]);
223+
} else {
224+
// Même si rien n'a changé, on met à jour le timestamp du cache
225+
$this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [
226+
'amount' => $cacheData['amount'],
227+
'donors' => $donors,
228+
'continuation_token' => $cacheData['continuation_token'],
229+
]);
191230
}
192231

193232
return ['stream' => $charityStream, 'amount' => $result['amount'], 'donors' => $donors];
@@ -205,12 +244,20 @@ private function fetchEventCardData(string $eventGuid): array
205244
$cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event)
206245
?? ['amount' => 0, 'donors' => 0, 'streams' => []];
207246

247+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
248+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
249+
return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0];
250+
}
251+
208252
$streams = $this->streamRepository->selectListByEvent($event);
209253
$oldAmount = $cacheData['amount'];
210254
$cacheData = $this->aggregateEventStreams($streams, $cacheData, true);
211255

212256
if ($oldAmount !== $cacheData['amount']) {
213257
$this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData);
258+
} else {
259+
// Même si rien n'a changé, on met à jour le timestamp du cache
260+
$this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData);
214261
}
215262

216263
return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors']];
@@ -276,17 +323,24 @@ public function widgetAlert(Request $request, Response $response, array $args):
276323
$cacheData = $this->widgetRepository->selectAlertWidgetCacheData($charityStream)
277324
?? ['continuation_token' => ''];
278325

279-
$result = $this->apiWrapper->getAllOrders(
280-
$charityStream->organization_slug,
281-
$charityStream->form_slug,
282-
0,
283-
$cacheData['continuation_token'],
284-
);
326+
if (!$this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
327+
$result = $this->apiWrapper->getAllOrders(
328+
$charityStream->organization_slug,
329+
$charityStream->form_slug,
330+
0,
331+
$cacheData['continuation_token'],
332+
);
285333

286-
if ($cacheData['continuation_token'] !== $result['continuation_token']) {
287-
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
288-
'continuation_token' => $result['continuation_token'],
289-
]);
334+
if ($cacheData['continuation_token'] !== $result['continuation_token']) {
335+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
336+
'continuation_token' => $result['continuation_token'],
337+
]);
338+
} else {
339+
// Même si rien n'a changé, on met à jour le timestamp du cache
340+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
341+
'continuation_token' => $cacheData['continuation_token'],
342+
]);
343+
}
290344
}
291345

292346
return $this->view->render($response, 'widget/alert.html.twig', [
@@ -312,6 +366,15 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar
312366
?? ['continuation_token' => ''];
313367

314368
try {
369+
// Si le cache est encore frais, on retourne un résultat vide (pas de nouvelles donations)
370+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
371+
return $this->jsonResponse($response, [
372+
'amount' => 0,
373+
'donations' => [],
374+
'continuation_token' => $cacheData['continuation_token'],
375+
]);
376+
}
377+
315378
$result = $this->apiWrapper->getAllOrders(
316379
$charityStream->organization_slug,
317380
$charityStream->form_slug,
@@ -323,6 +386,11 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar
323386
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
324387
'continuation_token' => $result['continuation_token'],
325388
]);
389+
} else {
390+
// Même si rien n'a changé, on met à jour le timestamp du cache
391+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
392+
'continuation_token' => $cacheData['continuation_token'],
393+
]);
326394
}
327395

328396
return $this->jsonResponse($response, $result);

src/Repositories/WidgetRepository.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,22 +115,50 @@ public function updateAlertWidget(string $guid, array $postData, ?string $image
115115
private function selectCacheData(string $table, string $column, string $guid): ?array
116116
{
117117
$stmt = $this->pdo->prepare(
118-
'SELECT cache_data FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?'
118+
'SELECT cache_data, cache_updated_at FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?'
119119
);
120120
$stmt->execute([$guid]);
121121
$data = $stmt->fetch();
122122

123-
return $data ? json_decode($data["cache_data"] ?? "", true) : null;
123+
if (!$data || !$data['cache_data']) {
124+
return null;
125+
}
126+
127+
$result = json_decode($data['cache_data'], true);
128+
if ($result !== null && isset($data['cache_updated_at'])) {
129+
$result['_cache_updated_at'] = $data['cache_updated_at'];
130+
}
131+
132+
return $result;
124133
}
125134

126135
private function updateCacheData(string $table, string $column, string $guid, array $data): void
127136
{
137+
// Remove internal metadata before persisting
138+
unset($data['_cache_updated_at']);
139+
128140
$stmt = $this->pdo->prepare(
129-
'UPDATE ' . $this->prefix . $table . ' SET cache_data = ? WHERE ' . $column . ' = ?'
141+
'UPDATE ' . $this->prefix . $table . ' SET cache_data = ?, cache_updated_at = NOW(6) WHERE ' . $column . ' = ?'
130142
);
131143
$stmt->execute([json_encode($data), $guid]);
132144
}
133145

146+
/**
147+
* Vérifie si le cache est encore frais (non expiré) selon le TTL donné en secondes.
148+
*/
149+
public function isCacheFresh(?array $cacheData, int $ttlSeconds): bool
150+
{
151+
if ($cacheData === null || !isset($cacheData['_cache_updated_at'])) {
152+
return false;
153+
}
154+
155+
$updatedAt = new \DateTime($cacheData['_cache_updated_at']);
156+
$now = new \DateTime();
157+
$age = $now->getTimestamp() - $updatedAt->getTimestamp();
158+
159+
return $age < $ttlSeconds;
160+
}
161+
134162
// ── Alert widget cache ────────────────────────────────────────
135163

136164
public function selectAlertWidgetCacheData(Stream $stream): ?array

0 commit comments

Comments
 (0)