-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: Add File-based Caching #862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
dfcdd61
feat: Implement caching for GitHub contribution stats with a 24-hour …
ironashram 13e83a0
refactor: Improve cache key generation and file handling in cache fun…
ironashram 197824b
style: run prettier
ironashram 0f7a62f
style: format code with prettier
ironashram 473c149
feat: prevent hash collisions, add file locking and cache unit test
ironashram f63392f
refactor: fix deepsource issues
ironashram 474059a
refactor: use CACHE_DURATION constant
ironashram File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,9 @@ yarn.lock | |
| package-lock.json | ||
| .vercel | ||
|
|
||
| # Cache directory | ||
| cache/ | ||
|
|
||
| # Local Configuration | ||
| .DS_Store | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * Simple file-based cache for GitHub contribution stats | ||
| * | ||
| * Caches stats for 24 hours to avoid repeated API calls | ||
| */ | ||
|
|
||
| // Default cache duration: 24 hours (in seconds) | ||
| define("CACHE_DURATION", 24 * 60 * 60); | ||
| define("CACHE_DIR", __DIR__ . "/../cache"); | ||
|
|
||
| /** | ||
| * Generate a cache key for a user's request | ||
| * | ||
| * Uses structured JSON format to prevent hash collisions between different | ||
| * user/options combinations that could produce the same concatenated string. | ||
| * | ||
| * @param string $user GitHub username | ||
| * @param array $options Additional options that affect the stats (mode, exclude_days, starting_year) | ||
| * @return string Cache key (filename-safe) | ||
| */ | ||
| function getCacheKey(string $user, array $options = []): string | ||
| { | ||
| ksort($options); | ||
| try { | ||
| $keyData = json_encode(["user" => $user, "options" => $options], JSON_THROW_ON_ERROR); | ||
| } catch (JsonException $e) { | ||
| // Fallback to simple concatenation if JSON encoding fails | ||
| error_log("Cache key JSON encoding failed: " . $e->getMessage()); | ||
| $keyData = $user . serialize($options); | ||
| } | ||
| return hash("sha256", $keyData); | ||
| } | ||
|
|
||
| /** | ||
| * Get the cache file path for a given key | ||
| * | ||
| * @param string $key Cache key | ||
| * @return string Full path to cache file | ||
| */ | ||
| function getCacheFilePath(string $key): string | ||
| { | ||
| return CACHE_DIR . "/" . $key . ".json"; | ||
| } | ||
|
|
||
| /** | ||
| * Ensure the cache directory exists | ||
| * | ||
| * @return bool True if directory exists or was created | ||
| */ | ||
| function ensureCacheDir(): bool | ||
| { | ||
| if (!is_dir(CACHE_DIR)) { | ||
| return mkdir(CACHE_DIR, 0755, true); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Get cached stats if available and not expired | ||
| * | ||
| * @param string $user GitHub username | ||
| * @param array $options Additional options | ||
| * @param int $maxAge Maximum age in seconds (default: 24 hours) | ||
| * @return array|null Cached stats array or null if not cached/expired | ||
| */ | ||
| function getCachedStats(string $user, array $options = [], int $maxAge = CACHE_DURATION): ?array | ||
| { | ||
| $key = getCacheKey($user, $options); | ||
| $filePath = getCacheFilePath($key); | ||
|
|
||
| if (!file_exists($filePath)) { | ||
| return null; | ||
| } | ||
|
|
||
| $mtime = filemtime($filePath); | ||
| if ($mtime === false) { | ||
| return null; | ||
| } | ||
|
|
||
| $fileAge = time() - $mtime; | ||
| if ($fileAge > $maxAge) { | ||
| unlink($filePath); | ||
| return null; | ||
| } | ||
|
|
||
| $handle = fopen($filePath, "r"); | ||
| if ($handle === false) { | ||
| return null; | ||
| } | ||
|
|
||
| if (!flock($handle, LOCK_SH)) { | ||
| fclose($handle); | ||
| return null; | ||
| } | ||
|
|
||
| $contents = stream_get_contents($handle); | ||
| flock($handle, LOCK_UN); | ||
| fclose($handle); | ||
|
|
||
| if ($contents === false || $contents === "") { | ||
| return null; | ||
| } | ||
|
|
||
| $data = json_decode($contents, true); | ||
| if (!is_array($data)) { | ||
| return null; | ||
| } | ||
|
|
||
| return $data; | ||
| } | ||
|
|
||
| /** | ||
| * Save stats to cache | ||
| * | ||
| * @param string $user GitHub username | ||
| * @param array $options Additional options | ||
| * @param array $stats Stats array to cache | ||
| * @return bool True if successfully cached | ||
| */ | ||
| function setCachedStats(string $user, array $options, array $stats): bool | ||
| { | ||
| if (!ensureCacheDir()) { | ||
| error_log("Failed to create cache directory: " . CACHE_DIR); | ||
| return false; | ||
| } | ||
|
|
||
| $key = getCacheKey($user, $options); | ||
| $filePath = getCacheFilePath($key); | ||
|
|
||
| $data = json_encode($stats); | ||
| if ($data === false) { | ||
| error_log("Failed to encode stats to JSON for user: " . $user); | ||
| return false; | ||
| } | ||
|
|
||
| $result = file_put_contents($filePath, $data, LOCK_EX); | ||
| if ($result === false) { | ||
| error_log("Failed to write cache file: " . $filePath); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Clear all expired cache files | ||
| * | ||
| * @param int $maxAge Maximum age in seconds | ||
| * @return int Number of files deleted | ||
| */ | ||
| function clearExpiredCache(int $maxAge = CACHE_DURATION): int | ||
| { | ||
| if (!is_dir(CACHE_DIR)) { | ||
| return 0; | ||
| } | ||
|
|
||
| $deleted = 0; | ||
| $files = glob(CACHE_DIR . "/*.json"); | ||
|
|
||
| if ($files === false) { | ||
| return 0; | ||
| } | ||
|
|
||
| foreach ($files as $file) { | ||
| $mtime = filemtime($file); | ||
| if ($mtime === false) { | ||
| continue; | ||
| } | ||
| $fileAge = time() - $mtime; | ||
| if ($fileAge > $maxAge) { | ||
| if (unlink($file)) { | ||
| $deleted++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $deleted; | ||
| } | ||
DenverCoder1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Clear cache for a specific user | ||
| * | ||
| * Note: This function only clears the cache for the user with empty/default options. | ||
| * Cache entries with non-empty options (starting_year, mode, exclude_days) will NOT | ||
| * be cleared. This is a limitation of the hash-based cache key system - we cannot | ||
| * enumerate all possible option combinations without storing additional metadata. | ||
| * | ||
| * @param string $user GitHub username | ||
| * @return bool True if cache was cleared (or didn't exist) | ||
| */ | ||
| function clearUserCache(string $user): bool | ||
| { | ||
| if (!is_dir(CACHE_DIR)) { | ||
| return true; | ||
| } | ||
|
|
||
| $key = getCacheKey($user, []); | ||
| $filePath = getCacheFilePath($key); | ||
|
|
||
| if (file_exists($filePath)) { | ||
| return unlink($filePath); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
ironashram marked this conversation as resolved.
Show resolved
Hide resolved
ironashram marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.