Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2,325 changes: 2,325 additions & 0 deletions docs/plans/2026-07-06-stack-cache-tags-and-tag-architecture-refactor.md

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions src/auth/src/EloquentUserProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete
* Boot-only. User providers are held by cached guards; runtime use mutates
* the provider used by every subsequent authentication lookup.
*
* @param null|array<string> $tags optional tag names enabling tag-based bulk flush; requires a TaggableStore in TagMode::Any
* @param null|array<string> $tags optional tag names enabling tag-based bulk flush; requires any-mode tag support
*
* @throws InvalidArgumentException when the resolved store is not supported
*/
Expand Down Expand Up @@ -426,11 +426,13 @@ protected function ensureSupportedAuthCacheStore(CacheRepository $cache): void
}

/**
* Ensure the resolved cache store supports tags and is in any-mode.
* Ensure the resolved cache store supports tags in any-mode.
*
* Auth caching only supports tag-based bulk flush via any-mode because:
* - any-mode keys are independent of tags, so reads and per-user
* forgets stay on the plain repo (no mode branching in the hot path);
* - stack caches can layer short-lived local reads over shared any-mode
* tag indexes without changing auth's plain-key read path;
* - the dynamic tag resolver can't cause cross-context invalidation
* bugs since the auto-invalidation listener never touches tags.
*
Expand All @@ -440,18 +442,20 @@ protected function ensureTaggableAnyModeStore(CacheRepository $cache): void
{
$store = $cache->getStore();

if (! $store instanceof TaggableStore) {
if (! $store instanceof TaggableStore || ! $store->supportsTags()) {
throw new InvalidArgumentException(sprintf(
'Auth user caching tags require a TaggableStore; got [%s]. See the auth cache documentation for supported stores.',
'Auth user caching tags require a store that supports tags; got [%s]. See the auth cache documentation for supported stores.',
$store::class,
));
}

if ($store->getTagMode() !== TagMode::Any) {
$mode = $store->getTagMode();

if ($mode !== TagMode::Any) {
throw new InvalidArgumentException(sprintf(
'Auth user caching tags require a store configured in TagMode::Any; got [%s] in mode [%s]. Configure a separate Redis store with tag_mode=any for auth caching.',
'Auth user caching tags require a store configured in TagMode::Any; got [%s] in mode [%s]. Configure a Redis store with tag_mode=any (or a stack over one) for auth caching.',
$store::class,
$store->getTagMode()->value,
$mode->value,
));
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/boost/docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ AUTH_USERS_CACHE_ENABLED=true
AUTH_USERS_CACHE_STORE=stack
```

Supported stores are `redis`, `database`, `file`, `swoole`, and `stack`. The `array`, `null`, `session`, and `failover` stores are rejected when the guard is resolved because they are either request-local, user-local, discard writes, or have fallback behavior that is not appropriate for authentication data. If you use `stack`, Hypervel only validates the outer stack store; unsupported inner stores such as `array`, `null`, `session`, or `failover` can still cause stale, missing, or unsafe auth cache behavior. Choose supported inner stores such as `swoole` and `redis`.
Supported stores are `redis`, `database`, `file`, `swoole`, and `stack`. The `array`, `null`, `session`, and `failover` stores are rejected when the guard is resolved because they are either request-local, user-local, discard writes, or have fallback behavior that is not appropriate for authentication data. For untagged auth caching, Hypervel validates the outer stack store; unsupported inner stores such as `array`, `null`, `session`, or `failover` can still cause stale, missing, or unsafe auth cache behavior. Choose supported inner stores such as `swoole` and `redis`. When auth cache tags are configured, the stack's tag composition is also validated.

When using a node-local store such as `swoole` or `file`, invalidation is local to that node. In a multi-node deployment using `stack` with a Swoole L1, a user update clears the current node's L1 and the shared backing store, while other nodes may serve their L1 entry until its short TTL expires. Use plain `redis` or `database` if you need strict cross-node consistency.

Expand Down Expand Up @@ -297,7 +297,7 @@ Then flush the tagged entries:
Cache::store('auth')->tags(['auth_users'])->flush();
```

Auth cache tags require a taggable store configured in `any` mode. Redis is the stock store that supports configurable tag modes. The default Redis tag mode is `all`, so use a separate Redis store with `tag_mode` set to `any` when enabling auth cache tags.
Auth cache tags require a store that supports tags in `any` mode. Use a Redis store with `tag_mode` set to `any`, or a valid cache stack whose taggable layers are all any-mode stores. The default Redis tag mode is `all`, so use a separate Redis store or stack when enabling auth cache tags.

You may also add per-request dynamic tags. This is useful when every cached user should keep a broad static tag, such as `auth_users`, plus a narrower request-specific tag, such as the current tenant:

Expand Down
50 changes: 46 additions & 4 deletions src/boost/docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Redis Tag Modes](#redis-tag-modes)
- [All Tag Mode](#all-tag-mode)
- [Any Tag Mode](#any-tag-mode)
- [Tagged Cache Stacks](#tagged-cache-stacks)
- [Pruning Stale Tag Entries](#pruning-stale-tag-entries)
- [Atomic Locks](#atomic-locks)
- [Managing Locks](#managing-locks)
Expand Down Expand Up @@ -168,6 +169,8 @@ This configuration aggregates two other cache stores: `swoole` and `redis`. When

When retrieving data, if there is a cache hit in the `swoole` layer, the data will be returned immediately and the Redis cache will not be queried. If there is a cache miss in the `swoole` layer, the stack driver will check the Redis layer. If Redis contains the value, the value will be returned and backfilled into the Swoole layer for future requests.

Locks and lock-backed helpers, such as `Cache::lock`, `Cache::funnel`, and `Cache::withoutOverlapping`, are delegated to the bottom layer of the stack. Use a lock-capable bottom store, such as Redis, when a stack is your default cache store.

<a name="session-cache"></a>
### Session Cache

Expand Down Expand Up @@ -528,7 +531,7 @@ cache()->remember('users', $seconds, function () {
## Cache Tags

> [!WARNING]
> Cache tags are supported by the `redis`, `array`, `failover`, and `null` cache drivers. They are not supported by the `file`, `database`, `swoole`, `stack`, `session`, or `memo` drivers.
> Cache tags are supported by the `redis`, `array`, `failover`, `null`, and `stack` cache drivers. Stack tags require an any-mode composition; see [Tagged Cache Stacks](#tagged-cache-stacks). Cache tags are not supported by the `file`, `database`, `swoole`, `session`, or `memo` drivers.

<a name="redis-tag-modes"></a>
### Redis Tag Modes
Expand Down Expand Up @@ -598,8 +601,10 @@ Because tags are invalidation indexes in `any` mode, flushing any one tag remove
Cache::tags(['user:42'])->flush();
```

Tag membership is synchronized by tagged writes. Plain `Cache::forget($key)` removes any-mode tag membership for that key, and a finite `Cache::touch($key, $ttl)` keeps the key and tag metadata TTLs in sync. Plain `put`, plain `forever`, and `touch($key, null)` are plain rewrites; they do not add tags or refresh tag metadata for an already-tagged value. To change a tagged value's TTL or tags, write it again through `tags()`.

> [!WARNING]
> In `any` mode, attempting to retrieve, check, pull, forget, or retrieve many cache items through a tagged cache will throw a `BadMethodCallException`. Use the direct `Cache::get`, `Cache::has`, `Cache::pull`, `Cache::forget`, and `Cache::many` methods with the full cache key instead.
> In `any` mode, attempting to retrieve, check, pull, forget, touch, or retrieve many cache items through a tagged cache will throw a `BadMethodCallException`. Use the direct `Cache::get`, `Cache::has`, `Cache::pull`, `Cache::forget`, `Cache::touch`, and `Cache::many` methods with the full cache key instead.

The `items` method returns a generator yielding all key / value pairs indexed by the given tags. This can be useful for debugging or bulk operations:

Expand All @@ -609,6 +614,43 @@ foreach (Cache::tags(['user:42'])->items() as $key => $value) {
}
```

<a name="tagged-cache-stacks"></a>
### Tagged Cache Stacks

The `stack` driver supports cache tags when its taggable layers are all configured in `any` mode. Tagged stack writes record tag indexes in the taggable layers, while reads continue to use the stack's plain-key path: an L1 hit returns immediately, and an L2 hit backfills upper layers. Flush tagged entries through `tags()`, then read them through the plain cache key:

```php
Cache::store('stack')->tags(['user:42'])->put('profile:42', $profile, 3600);

$profile = Cache::store('stack')->get('profile:42');

Cache::store('stack')->tags(['user:42'])->flush();
```

A stack supports tags when it has at least one taggable layer, and every layer from the first taggable layer down to the bottom is an any-mode taggable store. Non-taggable microcache layers may sit above that region:

```php
'auth-stack' => [
'driver' => 'stack',
'stores' => [
'swoole' => ['ttl' => 3],
'redis-any',
],
],

'redis-any' => [
'driver' => 'redis',
'connection' => 'cache',
'tag_mode' => 'any',
],
```

Putting a non-taggable layer below the taggable region is rejected because it can resurrect flushed values. For example, a `[redis-any, file]` stack could flush Redis, then miss Redis, read the old value from `file`, and backfill the flushed value into Redis again.

After a tag flush, non-taggable upper layers may still serve their cached value until that layer's TTL expires. This bounded staleness is the normal microcache tradeoff. Configure node-local upper layers with a short `ttl` override; without one, staleness is bounded by the cached item's own TTL. Use `Cache::store(...)->supportsTags()` to test whether a configured stack currently supports tags.

Direct stack writes and `Cache::touch()` use plain layer writes. For tagged stack items, write the value through `tags()` again when tag metadata must remain authoritative for a TTL or tag change.

<a name="pruning-stale-tag-entries"></a>
### Pruning Stale Tag Entries

Expand All @@ -626,7 +668,7 @@ You may schedule this command to run periodically based on how often tagged cach
## Atomic Locks

> [!WARNING]
> To utilize this feature, your application must be using the `redis`, `database`, `file`, or `array` cache driver as your application's default cache driver. For distributed locks, all servers must be communicating with the same central cache server.
> To utilize this feature, your application must be using the `redis`, `database`, `file`, `array`, or `stack` cache driver as your application's default cache driver. Stack locks are delegated to the bottom layer, so the bottom store must support locks. For distributed locks, all servers must be communicating with the same central cache server.

<a name="managing-locks"></a>
### Managing Locks
Expand Down Expand Up @@ -765,7 +807,7 @@ You may clear all atomic locks in the cache using the `flushLocks` method:
Cache::flushLocks();
```

The `flushLocks` method is supported by the `redis`, `database`, `file`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`.
The `flushLocks` method is supported by the `redis`, `database`, `file`, `array`, and `stack` cache drivers when their current configuration can flush locks. Stack stores delegate lock flushing to the bottom layer and support it only when that bottom layer supports flushing locks. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If the repository's configured store cannot currently flush locks, Hypervel will throw a `BadMethodCallException`. Direct store-level `flushLocks` calls still throw a `RuntimeException` when lock storage is shared with regular cache storage.

> [!WARNING]
> The `flushLocks` method removes every lock in the lock store, regardless of which application or process owns the lock. Use it carefully in shared environments.
Expand Down
4 changes: 4 additions & 0 deletions src/boost/docs/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@ The blacklist uses the configured storage provider:
],
```

The default tagged-cache storage requires your default cache store to support tags. Both all-mode and any-mode tagged stores are supported. When using any-mode tags, blacklist entries are written through tags but read and removed by a private plain-key prefix.

If the blacklist store uses a cache stack or any node-local tier, a revoked token may still validate on another node until that node's local cache entry expires. Keep the upper-tier TTL short, or use a fully shared store such as Redis when revocation must be visible immediately across all nodes.

You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress:

```php
Expand Down
8 changes: 8 additions & 0 deletions src/cache/src/AbstractArrayStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ public function flush(): bool
return true;
}

/**
* Determine if the store can currently flush locks.
*/
public function supportsFlushingLocks(): bool
{
return true;
}

/**
* Remove all locks from the store.
*
Expand Down
121 changes: 121 additions & 0 deletions src/cache/src/AnyModeTaggedCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache;

use BadMethodCallException;
use DateInterval;
use DateTimeInterface;
use UnitEnum;

/**
* Tagged cache for any-mode tag semantics.
*
* Tags are invalidation indexes only: items live under their plain cache
* keys, tags are recorded on writes, and flushing any one tag removes every
* item written with it. Reads, existence checks, per-key deletes, and TTL
* adjustments are not tag operations in this mode.
*/
abstract class AnyModeTaggedCache extends TaggedCache
{
/**
* Retrieve an item from the cache by key.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function get(array|UnitEnum|string $key, mixed $default = null): mixed
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::get() directly with the full key.'
);
}

/**
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function getRaw(UnitEnum|string $key): mixed
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::get() directly with the full key.'
);
}

/**
* Retrieve multiple items from the cache by key.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function many(array $keys): array
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::many() directly with the full keys.'
);
}

/**
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function manyRaw(array $keys): array
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::many() directly with the full keys.'
);
}

/**
* Determine if an item exists in the cache.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function has(array|UnitEnum|string $key): bool
{
throw new BadMethodCallException(
'Cannot check existence via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::has() directly with the full key.'
);
}

/**
* Retrieve an item from the cache and delete it.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function pull(UnitEnum|string $key, mixed $default = null): mixed
{
throw new BadMethodCallException(
'Cannot pull items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::pull() directly with the full key.'
);
}

/**
* Remove an item from the cache.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function forget(UnitEnum|string $key): bool
{
throw new BadMethodCallException(
'Cannot forget items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::forget() directly with the full key, or flush() to remove all tagged items.'
);
}

/**
* Set the expiration of a cached item.
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool
{
throw new BadMethodCallException(
'Cannot touch items via tags in any mode. Re-put the item through tags() to change '
. 'its TTL; a direct Cache::touch() uses the store\'s plain-key semantics.'
);
}
}
8 changes: 8 additions & 0 deletions src/cache/src/DatabaseStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,14 @@ public function flush(): bool
return true;
}

/**
* Determine if the store can currently flush locks.
*/
public function supportsFlushingLocks(): bool
{
return $this->hasSeparateLockStore();
}
Comment thread
binaryfire marked this conversation as resolved.

/**
* Remove all locks from the store.
*
Expand Down
10 changes: 9 additions & 1 deletion src/cache/src/FileStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class FileStore implements CanFlushLocks, LockProvider, Store
/**
* The file cache lock directory.
*/
protected ?string $lockDirectory;
protected ?string $lockDirectory = null;

/**
* Octal representation of the cache file permissions.
Expand Down Expand Up @@ -224,6 +224,14 @@ public function flush(): bool
return true;
}

/**
* Determine if the store can currently flush locks.
*/
public function supportsFlushingLocks(): bool
{
return $this->hasSeparateLockStore();
}
Comment thread
binaryfire marked this conversation as resolved.

/**
* Remove all locks from the store.
*
Expand Down
42 changes: 42 additions & 0 deletions src/cache/src/NamespacedTagSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache;

/**
* Base for tag sets whose tags namespace the item keyspace.
*
* Items are stored under keys derived from the tag set and must be read
* back through the same tags.
*/
abstract class NamespacedTagSet extends TagSet
{
/**
* Get a unique namespace that changes when any of the tags are flushed.
*/
public function getNamespace(): string
{
return implode('|', $this->tagIds());
}

/**
* Get an array of tag identifiers for all of the tags in the set.
*
* @return array<string>
*/
public function tagIds(): array
{
return array_map([$this, 'tagId'], $this->names);
}

/**
* Get the unique tag identifier for a given tag.
*/
abstract public function tagId(string $name): string;

/**
* Get the tag identifier key for a given tag.
*/
abstract public function tagKey(string $name): string;
}
Loading