Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5bd74f7
Expand watcher consolidation TODO coverage
binaryfire Aug 21, 2026
6e77195
Document audit correctness follow-up plan
binaryfire Aug 21, 2026
3180762
Clarify worker-lifetime cache key bounds
binaryfire Aug 21, 2026
b260dad
Correct pooled Redis command and connection contracts
binaryfire Aug 21, 2026
ac6c23d
Configure secondary Swoole ports explicitly
binaryfire Aug 21, 2026
2ec7af8
Bound event preparation by registrations
binaryfire Aug 21, 2026
56b3d89
Reclaim expired worker array cache records
binaryfire Aug 21, 2026
93985f0
Preserve whole-second expiration deadlines
binaryfire Aug 21, 2026
78314a9
Preserve Redis all-tag expiry metadata
binaryfire Aug 21, 2026
4cc7324
Reclaim released coroutine mutex channels
binaryfire Aug 21, 2026
e167ecb
Render scheduler runtimes with correct units
binaryfire Aug 21, 2026
346487b
Make database assertion diagnostics encoding-safe
binaryfire Aug 21, 2026
21fa931
Complete fake HTTP sink writes safely
binaryfire Aug 21, 2026
dd096e5
Complete log stream writes without replay
binaryfire Aug 21, 2026
58c8810
Remove premature Boost installation guidance
binaryfire Aug 21, 2026
d715202
Finalize the audit correctness implementation plan
binaryfire Aug 21, 2026
ed9d04f
Fix database limiter first-use deadlocks
binaryfire Aug 22, 2026
559ce8d
Preserve future deadlines with mutable dates
binaryfire Aug 22, 2026
2d3015f
Normalize Redis tag TTLs before metadata writes
binaryfire Aug 22, 2026
abcd330
Use the shared database constraint test namespace
binaryfire Aug 22, 2026
f6e0c0b
Clarify worker-lifetime lookup cache guidance
binaryfire Aug 22, 2026
2b2863c
Update the audit correctness implementation plan
binaryfire Aug 22, 2026
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ Decide where state lives before writing code:
- **Use `Hypervel\Context\CoroutineContext` for invocation-scoped state** — anything that must not be visible to other concurrent coroutines in the same worker. Static properties and singleton fields leak across coroutines: whatever one coroutine sets becomes visible to all others in the worker. Use the established key-naming convention: `__<package>.<key>` value prefix, `_CONTEXT_KEY` / `_CONTEXT_KEY_PREFIX` constant suffixes, public only when other classes or tests reference the constant. Do not use `Hypervel\Support\Facades\Context` as the low-level coroutine store; it provides Laravel-style application context instead.
- **Configure process-global values only during worker boot** — config is a process-global singleton, so `Config::set()` during request handling changes behavior for every concurrent request in the worker. Never mutate config for request-specific behavior; use `CoroutineContext` or middleware instead. Provider boot-time configuration is fine — it runs once per worker.
- **Name static cache properties for what they store** — not with a `Cache` suffix; static properties in Swoole workers are caches by nature. Exception: matching an existing Laravel-ported pattern in the same class (e.g. `$classCastCache`, `$attributeCastCache` on `HasAttributes`).
- **Bound worker-lifetime lookup caches** — any internal lookup cache retained across requests in a worker—for example, in static properties or singleton instances—must have a naturally limited set of keys or discard entries that are safe to recompute. Do not add a size limit merely to hide growth from request- or user-derived keys. This governs framework-derived caches, not application-owned cache stores such as the `worker-array` driver.
- **Review worker-lifetime state explicitly** — whenever a change introduces or modifies static properties/caches, singletons or other long-lived state, STOP and report the Swoole persistence impact (memory leaks, cross-request behavior) with a recommendation.
- **Document worker-lifetime mutators** — when adding or touching a public method that mutates static state, singleton-held configuration, manager registries, cached drivers, global callbacks, or other worker-lifetime state, add a short warning to the method docblock if the method is intended only for boot-time configuration or tests. Use the tag-first format so humans and LLMs can recognize it quickly:
- `Boot-only.` — for startup configuration methods
Expand Down

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

## Boost

- Implement Hypervel Boost's installation flow and revisit the Boost section of `installation.md` once the implementation is complete. The current docs describe the intended `composer require hypervel/boost --dev` and `php artisan boost:install` workflow, but `src/boost` currently contains the documentation package only. Correct fix: add the interactive installer command and supporting tools, consume the existing Wayfinder and Horizon skill templates, then update the installation docs for any differences from Laravel Boost.
- Implement Hypervel Boost's interactive installer and supporting AI tools, consuming the existing Wayfinder and Horizon skill templates where appropriate. Once the package ships working functionality, add and verify its installation documentation.

## Wayfinder

Expand Down Expand Up @@ -48,7 +48,8 @@
- Consolidate the two find-based watcher drivers while adding deletion detection:
- Keep the public `FindDriver` name, replace its rolling `find -mmin` implementation with `FindNewerDriver`'s alternating reference-file and `find -newer` design, then remove `FindNewerDriver`. Do not retain a compatibility alias or add a mode setting: the two implementations have different state and lifecycle requirements, and the older mode provides no useful capability worth exposing.
- [`find -newer` is part of the POSIX `find` surface](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/find.html), while `-mmin` is an extension. The current `FindDriver` also requires GNU `gfind` on macOS even though `FindNewerDriver` works with the system `find`. Hyperf [added `FindNewerDriver` specifically for macOS, Linux, and Docker compatibility](https://github.com/hyperf/hyperf/pull/3170) but retained the older driver; Hypervel does not need to carry both forward.
- Preserve the reference driver's correctness properties: create and own unique temporary reference files, record the next cutoff before scanning, advance the cutoff only after a successful scan, retain the last successful cutoff across failures, and clean up safely across stop and restart. This avoids the rolling window losing changes after a failed scan and avoids `FindDriver`'s whole-second `filemtime()` de-duplication missing another modification within the same second. Both approaches traverse the same directory tree, so the old mode has no meaningful performance advantage. An unwritable temporary directory is the only realistic boundary where the old mode could start while the reference mode cannot, and that is not a useful development environment to support with another driver.
- Preserve the reference driver's correctness properties: create and own unique temporary reference files, record the next cutoff before scanning, advance the cutoff only after a successful scan, retain the last successful cutoff across failures, and clean up safely across stop and restart. This avoids the rolling window losing changes after a failed scan, avoids changes aging out because each polling delay begins only after the preceding scan completes, and avoids `FindDriver`'s whole-second `filemtime()` de-duplication missing another modification within the same second. It also removes the `-mmin` formatting failure where a positive `scan_interval` below roughly 300 ms becomes `-0.00` and matches nothing. Cover changes made during a slow scan and very small positive scan intervals. Both approaches traverse the same directory tree, so the old mode has no meaningful performance advantage. An unwritable temporary directory is the only realistic boundary where the old mode could start while the reference mode cannot, and that is not a useful development environment to support with another driver.
- Use unambiguous NUL-delimited path output for both change detection and inventory reconciliation, with an emission strategy supported by every target system `find`; do not retain the current `-print` plus newline-splitting protocol, which corrupts valid filenames containing embedded newlines. Cover newline-containing filenames in both watched directories and explicit file targets.
- Keep a lightweight inventory of matched paths, reconcile it during each successful scan, and report removed paths without hashing file contents. Cover file and directory deletion, renames, newly discovered paths, command failures, repeated lifecycle calls, stop during an active scan, and restart after cleanup. Migrate the useful `FindNewerDriver` coverage to `FindDriver` and remove tests that exist only for the discarded `-mmin` behavior.
- Update `config/watcher.php` and `watcher.md` to expose only `ScanFileDriver`, `FindDriver`, and `FswatchDriver`. Add a concise "Choosing a Driver" subsection after the driver table: recommend `ScanFileDriver` as the dependency-free and most portable default, while noting that hashing every watched file costs more polling I/O as the tree grows; recommend `FswatchDriver` for large trees on native filesystems when its dependency and operating-system event delivery are suitable, since it has the lowest steady-state work; and describe `FindDriver` as the Unix polling middle ground when `fswatch` is unavailable, using file metadata rather than reading and hashing file contents. Mention that polling is the safer choice where container, virtual-machine, or network mounts do not forward filesystem events reliably. Update the table's detected-change entry after deletion reconciliation is implemented rather than documenting the future behavior early.

Expand All @@ -59,7 +60,6 @@

## Redis

- Audit transformed Redis command wrapper return types against serializer-configured phpredis connections. For example, `RedisConnection::callGet(): ?string` can receive unserialized non-string values from phpredis when a serializer is enabled under `strict_types`; check the other `call*` wrappers for the same mismatch and update signatures/tests to match real client behavior.
- Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then.

## Collections
Expand Down
2 changes: 0 additions & 2 deletions src/boost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,3 @@ Boost for Hypervel
===

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/boost)

Documentation: https://hypervel.org/docs/installation#hypervel-and-ai
2 changes: 1 addition & 1 deletion src/boost/composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hypervel/boost",
"type": "library",
"description": "AI agent tools and guidelines for Hypervel applications.",
"description": "Reserved package for future Hypervel AI tooling.",
"license": "MIT",
"keywords": [
"php",
Expand Down
120 changes: 105 additions & 15 deletions src/cache/src/AbstractArrayStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,20 @@ public function get(string $key): mixed
return null;
}

$expiresAt = $item['expiresAt'];

if ($expiresAt !== 0.0 && (now()->getPreciseTimestamp(3) / 1000) >= $expiresAt) {
$this->forget($key);

return null;
}

return $this->serializesValues ? $this->unserialize($item['value']) : $item['value'];
return $this->valueFromItem($key, $item);
}

/**
* Store an item in the cache for a given number of seconds.
*/
public function put(string $key, mixed $value, int $seconds): bool
{
$expiresAt = $this->calculateExpiration($seconds);

$this->reclaimExpiredRecords();
$this->putCacheItem($key, [
'value' => $this->serializesValues ? serialize($value) : $value,
'expiresAt' => $this->calculateExpiration($seconds),
'expiresAt' => $expiresAt,
]);

return true;
Expand All @@ -109,12 +104,20 @@ public function put(string $key, mixed $value, int $seconds): bool
public function increment(string $key, int $value = 1): int
{
// When backed by WorkerArrayStore, this read/modify/write path is shared across coroutines; keep it non-yielding.
if (! is_null($existing = $this->get($key))) {
$item = $this->getCacheItem($key);
$existing = null;
$currentTimestamp = null;

if ($item !== null) {
$currentTimestamp = $item['expiresAt'] === 0.0 ? null : $this->currentPreciseTimestamp();
$existing = $this->valueFromItem($key, $item, $currentTimestamp);
}

if ($existing !== null) {
$incremented = ((int) $existing) + $value;

/** @var array{value: mixed, expiresAt: float} $item */
$item = $this->getCacheItem($key);
$item['value'] = $this->serializesValues ? serialize($incremented) : $incremented;
$this->reclaimExpiredRecords($currentTimestamp);
$this->putCacheItem($key, $item);

return $incremented;
Expand Down Expand Up @@ -152,7 +155,20 @@ public function touch(string $key, int $seconds): bool
return false;
}

$currentTimestamp = null;

if ($item['expiresAt'] !== 0.0) {
$currentTimestamp = $this->currentPreciseTimestamp();

if ($this->isCacheItemExpired($item, $currentTimestamp)) {
$this->forget($key);

return false;
}
}

$item['expiresAt'] = $this->calculateExpiration($seconds);
$this->reclaimExpiredRecords($currentTimestamp);
$this->putCacheItem($key, $item);

return true;
Expand Down Expand Up @@ -237,7 +253,20 @@ public function hasSeparateLockStore(): bool
*
* @return null|array{owner: ?string, expiresAt: ?CarbonImmutable}
*/
abstract public function getLockRecord(string $name): ?array;
public function getLockRecord(string $name): ?array
{
$record = $this->getLockRecords()[$name] ?? null;

// Permanent locks need no clock read.
if ($record !== null && $record['expiresAt'] !== null
&& $this->isLockRecordExpired($record['expiresAt'], CarbonImmutable::now())) {
$this->forgetLockRecord($name);

return null;
}

return $record;
}

/**
* Store the lock record for the given name.
Expand All @@ -256,6 +285,13 @@ abstract public function forgetLockRecord(string $name): void;
*/
abstract public function clearLockRecords(): void;

/**
* Get all lock records.
*
* @return array<string, array{owner: ?string, expiresAt: ?CarbonImmutable}>
*/
abstract protected function getLockRecords(): array;

/**
* Get the cached item for the given key.
*
Expand Down Expand Up @@ -287,6 +323,52 @@ abstract protected function clearCacheItems(): void;
*/
abstract protected function getCacheItems(): array;

/**
* Perform maintenance before writing one record.
*/
protected function reclaimExpiredRecords(?float $currentTimestamp = null): void
{
}

/**
* Retrieve and decode a cached item after applying its expiration.
*
* @param array{value: mixed, expiresAt: float} $item
*/
protected function valueFromItem(string $key, array $item, ?float $currentTimestamp = null): mixed
{
if ($item['expiresAt'] !== 0.0
&& $this->isCacheItemExpired($item, $currentTimestamp ?? $this->currentPreciseTimestamp())) {
$this->forget($key);

return null;
}

return $this->serializesValues ? $this->unserialize($item['value']) : $item['value'];
}

/**
* Determine if a cached item has expired.
*
* Callers must exclude permanent items whose expiration is 0.0.
*
* @param array{value: mixed, expiresAt: float} $item
*/
protected function isCacheItemExpired(array $item, float $currentTimestamp): bool
{
return $currentTimestamp >= $item['expiresAt'];
}

/**
* Determine if a lock record has expired.
*
* The inclusive boundary treats a lock as expired at its expiry instant.
*/
protected function isLockRecordExpired(CarbonImmutable $expiresAt, CarbonImmutable $currentTime): bool
{
return $expiresAt <= $currentTime;
}

/**
* Get the expiration time of the key.
*/
Expand All @@ -300,7 +382,15 @@ protected function calculateExpiration(int $seconds): float
*/
protected function toTimestamp(int $seconds): float
{
return $seconds > 0 ? (now()->getPreciseTimestamp(3) / 1000) + $seconds : 0;
return $seconds > 0 ? $this->currentPreciseTimestamp() + $seconds : 0;
}

/**
* Get the current UNIX timestamp with millisecond precision.
*/
protected function currentPreciseTimestamp(): float
{
return now()->getPreciseTimestamp(3) / 1000;
}

/**
Expand Down
10 changes: 0 additions & 10 deletions src/cache/src/ArrayStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,6 @@ protected function getCacheItems(): array
return CoroutineContext::get($this->storageContextKey, []);
}

/**
* Get the lock record for the given name.
*
* @return null|array{owner: ?string, expiresAt: ?CarbonImmutable}
*/
public function getLockRecord(string $name): ?array
{
return $this->getLockRecords()[$name] ?? null;
}

/**
* Store the lock record for the given name.
*
Expand Down
2 changes: 1 addition & 1 deletion src/cache/src/DatabaseLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ protected function expiresAt(?int $seconds = null): int

$lockTimeout = $seconds > 0 ? $seconds : $this->defaultTimeoutInSeconds;

return $this->currentTime() + $lockTimeout;
return $this->availableAt($lockTimeout);
}

/**
Expand Down
10 changes: 6 additions & 4 deletions src/cache/src/DatabaseStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ public function putMany(array $values, int $seconds): bool

$serializedValues = [];

$expiration = $this->getTime() + $seconds;
$expiration = $this->availableAt($seconds);

foreach ($values as $key => $value) {
$serializedValues[] = [
Expand All @@ -203,7 +203,7 @@ public function add(string $key, mixed $value, int $seconds): bool

$key = $this->prefix . $key;
$value = $this->serialize($value);
$expiration = $this->getTime() + $seconds;
$expiration = $this->availableAt($seconds);

return $this->table()->insertOrIgnore(compact('key', 'value', 'expiration')) > 0;
}
Expand Down Expand Up @@ -316,10 +316,12 @@ public function restoreLock(string $name, string $owner): DatabaseLock
*/
public function touch(string $key, int $seconds): bool
{
$now = $this->getTime();

return (bool) $this->table()
->where('key', '=', $this->getPrefix() . $key)
->where('expiration', '>', $now = $this->getTime())
->update(['expiration' => $now + $seconds]);
->where('expiration', '>', $now)
->update(['expiration' => $this->availableAt($seconds)]);
}

/**
Expand Down
Loading