GraphQL response cache can miss keys on invalidation under concurrent requests
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 52/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Lĩnh vực
- api, backend-api-design
Hướng nghiên cứu
Start with src/GraphQL/ResponseCache/DefaultCache.php, especially the tracked-key logic at lines 38–52, and trace how get(), put(), and handleInvalidationEvent() interact. Reproduce with concurrent queries and a shared cache store as described; then add regression coverage for concurrent caching and invalidation. Done means content events invalidate every cached response, including when requests overlap.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Bug description
Statamic\GraphQL\ResponseCache\DefaultCache invalidates by forgetting the keys it has recorded under gql-cache:tracked-responses. That list is maintained in track() with a read-modify-write (Cache::get → push → Cache::put) that is not atomic, so when several GraphQL requests are cached at the same moment they overwrite each other's copy of the list and some keys are never recorded.
A response whose key was dropped is never forgotten by handleInvalidationEvent(). It keeps being served until statamic.graphql.cache.expiry runs out (60 minutes by default), no matter how many EntrySaved or other content events fire in between.
We hit this on a headless site where the front end fires the entry-by-slug query, the listing query and a metadata query for the same page in parallel. A slug was queried a minute before its entry was created, so the null response was cached; the entry was then published from the CP (which did fire EntrySaved, and other keys were flushed), but this one key had been lost from the tracked list and the page 404'd for the full hour while its siblings rendered.
I would have expected every cached response to be dropped on a content event, whatever the request concurrency.
How to reproduce
- Fresh install with GraphQL enabled and a cache store that is shared across PHP workers (
databasehere;redisorfilebehave the same, onlyarrayhides it). - Serve the site with more than one PHP worker, for example:
cd public && PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:8123 index.php - Fire 30 distinct queries concurrently, then compare the tracked list with what was actually stored:
php artisan cache:clear seq 1 30 | xargs -P 30 -I{} curl -s -o /dev/null -X POST http://127.0.0.1:8123/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"{ entry(collection: \"pages\", slug: \"missing-{}\") { id } }"}' php artisan tinker --execute 'echo count(Cache::get("gql-cache:tracked-responses", []))." tracked, ".DB::table("cache")->where("key","like","%gql-cache:%")->where("key","not like","%tracked%")->count()." stored";' - Fire any content event and count what survives:
php artisan tinker --execute 'event(new Statamic\Events\EntrySaved(Statamic\Facades\Entry::query()->first())); echo DB::table("cache")->where("key","like","%gql-cache:%")->where("key","not like","%tracked%")->count()." still cached";'
Five runs of step 3 on a database cache store gave 29 of 30, 19 of 30, 30 of 30, 28 of 30 and 27 of 30 tracked. After the last run, step 4 left 3 responses in the cache that no event will ever remove. With a single worker (php artisan serve without PHP_CLI_SERVER_WORKERS) the requests are serialised and it never reproduces, which is probably why it has gone unnoticed.
The code in question, unchanged on 6.x:
https://github.com/statamic/cms/blob/6.x/src/GraphQL/ResponseCache/DefaultCache.php#L38-L52
Suggested fix
Drop the tracked list and key responses under a generation number instead, so invalidation is a single atomic operation:
protected function getCacheKey(Request $request)
{
return 'gql-cache:'.$this->generation().':'.md5($query).'_'.md5(json_encode($vars));
}
public function handleInvalidationEvent(Event $event)
{
if (Cache::increment('gql-cache:generation') === false) {
Cache::add('gql-cache:generation', 1); // database store returns false on a missing key
}
}
Superseded generations age out at the existing expiry. Two details worth keeping if you go this way: read the generation once in get() and reuse it in put() (the middleware calls them either side of executing the query), so a response computed before an event cannot land in the new generation; and note that DatabaseStore::increment() returns false rather than creating a missing key, so the counter needs seeding. We are running this shape in production behind our own ResponseCache binding and can send a PR if it would help.
Logs
No response
Environment
Environment
Laravel Version: 13.33.0
PHP Version: 8.5.0
Composer Version: 2.8.12
Environment: local
Debug Mode: ENABLED
Maintenance Mode: OFF
Timezone: UTC
Locale: en
Cache
Config: NOT CACHED
Events: NOT CACHED
Routes: NOT CACHED
Views: CACHED
Drivers
Broadcasting: log
Cache: database
Database: sqlite
Logs: stack / single
Mail: log
Queue: database
Session: database
Storage
public/storage: NOT LINKED
Statamic
Addons: 2
License Key: Not set
Sites: 3
Stache Watcher: Enabled (auto)
Static Caching: Disabled
Version: 6.34.0 PRO
Statamic Addons
cboxdk/statamic-mcp: 3.0.0
statamic/eloquent-driver: 5.11.1
Statamic Eloquent Driver
Addon Settings: file
Asset Containers: file
Assets: eloquent
Blueprints: file
Collection Trees: eloquent
Collections: file
Entries: eloquent
Fieldsets: file
Form Submissions: eloquent
Forms: file
Global Sets: file
Global Variables: eloquent
Navigation Trees: eloquent
Navigations: file
Revisions: eloquent
Sites: file
Taxonomies: file
Terms: eloquent
Tokens: eloquent
Installation
Existing Laravel app
Additional details
Headless: the GraphQL API is the only consumer of the content, statamic.routes.enabled is false and the entries live in the database via the Eloquent driver. Production runs the same versions on MySQL with the database cache store and two or more PHP workers per instance.
- Ngôn ngữ chính
- PHP
- Star
- 4.9k
- Fork
- 647
- Merge trung bình
- 1 ngày 7 giờ
- Pull request đã merge (30 ngày)
- 103
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của statamic/cms
-
preferences
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
There's a default crop when cropping imagesCó thể đã có người làm @jaygeorge đã nhận 1 ngày trước. Đang mởassets
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
statamic/cms#15567 · 1 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Make native filesystem directory permissions configurable to support shared POSIX ACL accessĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
accessibility
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
antlers
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
Maintainer thường phản hồi trong vòng 1 ngày
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
scanaislop/aislop#482 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
documentation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
humanmade/publication-checklist#73 · 1 bình luận ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
Werkstattl/OpenBlogware#92 ·