Skip to content

Commit fce5c30

Browse files
committed
feat: add orchestration layer for incremental builds
Extract higher-level incremental build classes from render-guides: - PropagationResult: DTO for dirty propagation results - CacheVersioning: Cache version validation (PHP version, format version) - DirtyPropagator: Propagates dirty state through dependency graph - GlobalInvalidationDetector: Detects when full rebuild is needed - IncrementalBuildCache: Main cache orchestrator with sharded storage Security hardening includes: - MAX_EXPORTS and MAX_OUTPUT_PATHS limits (100k each) - Input validation for all JSON data - Path validation for sharded storage - SplQueue for O(1) queue operations All classes have comprehensive unit tests (75 new tests).
1 parent bf23325 commit fce5c30

12 files changed

+2966
-1
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of phpDocumentor.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @link https://phpdoc.org
12+
*/
13+
14+
namespace phpDocumentor\Guides\Build\IncrementalBuild;
15+
16+
use function explode;
17+
use function is_numeric;
18+
use function is_string;
19+
use function str_starts_with;
20+
use function substr;
21+
use function time;
22+
23+
use const PHP_MAJOR_VERSION;
24+
use const PHP_MINOR_VERSION;
25+
use const PHP_VERSION;
26+
27+
/**
28+
* Handles cache versioning and validation.
29+
*
30+
* Cache is invalidated when:
31+
* - Cache format version changes
32+
* - PHP major.minor version changes (affects serialization)
33+
* - Package major version changes (may have breaking changes)
34+
*/
35+
final class CacheVersioning
36+
{
37+
/**
38+
* Current cache format version.
39+
* Increment when cache structure changes incompatibly.
40+
*/
41+
private const CACHE_VERSION = 1;
42+
43+
/** Minimum valid major version for package version string */
44+
private const MIN_VERSION_MAJOR = 0;
45+
46+
/** @param string $packageVersion Package version for additional validation */
47+
public function __construct(
48+
private readonly string $packageVersion = '1.0.0',
49+
) {
50+
}
51+
52+
/**
53+
* Check if cached metadata is still valid.
54+
*
55+
* @param array<string, mixed> $metadata Cached metadata
56+
*
57+
* @return bool True if cache is valid
58+
*/
59+
public function isCacheValid(array $metadata): bool
60+
{
61+
// Check cache version
62+
if (($metadata['version'] ?? 0) !== self::CACHE_VERSION) {
63+
return false;
64+
}
65+
66+
// Check PHP major.minor version (patch changes are compatible)
67+
$cachedPhpVersion = $metadata['phpVersion'] ?? '';
68+
if (!is_string($cachedPhpVersion)) {
69+
return false;
70+
}
71+
72+
$currentPhpMajor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
73+
if (!str_starts_with($cachedPhpVersion, $currentPhpMajor)) {
74+
return false;
75+
}
76+
77+
// Check package major version (major version changes may break cache compatibility)
78+
$cachedPackageVersion = $metadata['packageVersion'] ?? '';
79+
if (!is_string($cachedPackageVersion)) {
80+
return false;
81+
}
82+
83+
return $this->isMajorVersionCompatible($cachedPackageVersion, $this->packageVersion);
84+
}
85+
86+
/**
87+
* Check if two version strings have the same major version.
88+
*/
89+
private function isMajorVersionCompatible(string $cached, string $current): bool
90+
{
91+
$cachedMajor = $this->extractMajorVersion($cached);
92+
$currentMajor = $this->extractMajorVersion($current);
93+
94+
// If either can't be parsed, assume incompatible
95+
if ($cachedMajor === null || $currentMajor === null) {
96+
return false;
97+
}
98+
99+
return $cachedMajor === $currentMajor;
100+
}
101+
102+
/**
103+
* Extract major version from semver string.
104+
*
105+
* @return int|null Major version or null if unparseable
106+
*/
107+
private function extractMajorVersion(string $version): int|null
108+
{
109+
// Handle versions with 'v' prefix (e.g., 'v1.2.3')
110+
if (str_starts_with($version, 'v')) {
111+
$version = substr($version, 1);
112+
}
113+
114+
$parts = explode('.', $version);
115+
if ($parts === [] || !is_numeric($parts[0])) {
116+
return null;
117+
}
118+
119+
$major = (int) $parts[0];
120+
121+
return $major >= self::MIN_VERSION_MAJOR ? $major : null;
122+
}
123+
124+
/**
125+
* Create metadata for cache persistence.
126+
*
127+
* @param string $settingsHash Hash of project settings
128+
*
129+
* @return array<string, mixed>
130+
*/
131+
public function createMetadata(string $settingsHash = ''): array
132+
{
133+
return [
134+
'version' => self::CACHE_VERSION,
135+
'phpVersion' => PHP_VERSION,
136+
'packageVersion' => $this->packageVersion,
137+
'settingsHash' => $settingsHash,
138+
'createdAt' => time(),
139+
];
140+
}
141+
142+
/**
143+
* Get current cache version.
144+
*/
145+
public function getCacheVersion(): int
146+
{
147+
return self::CACHE_VERSION;
148+
}
149+
}

packages/guides/src/Build/IncrementalBuild/DependencyGraph.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@
4444
*/
4545
final class DependencyGraph
4646
{
47-
/** Maximum number of documents allowed in the graph to prevent memory exhaustion */
47+
/**
48+
* Maximum number of documents allowed in the graph to prevent memory exhaustion.
49+
* Consistent with IncrementalBuildCache::MAX_EXPORTS, DirtyPropagator::MAX_PROPAGATION_VISITS,
50+
* and PropagationResult::MAX_DOCUMENTS.
51+
*/
4852
private const MAX_DOCUMENTS = 100_000;
4953

5054
/** Maximum number of imports per document to prevent memory exhaustion */
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of phpDocumentor.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @link https://phpdoc.org
12+
*/
13+
14+
namespace phpDocumentor\Guides\Build\IncrementalBuild;
15+
16+
use SplQueue;
17+
18+
use function array_diff;
19+
use function array_flip;
20+
use function array_keys;
21+
use function array_merge;
22+
use function array_unique;
23+
use function array_values;
24+
use function count;
25+
26+
/**
27+
* Propagates dirty state through the dependency graph.
28+
*
29+
* When a document's exports change, all documents that import from it
30+
* must also be re-rendered to update their cross-references.
31+
*
32+
* Uses SplQueue for O(1) queue operations instead of array_shift which is O(n).
33+
*/
34+
final class DirtyPropagator
35+
{
36+
/**
37+
* Maximum visited documents during propagation (defense-in-depth).
38+
* Consistent with PropagationResult::MAX_DOCUMENTS and IncrementalBuildCache::MAX_EXPORTS.
39+
*/
40+
private const MAX_PROPAGATION_VISITS = 100_000;
41+
42+
/**
43+
* Propagate dirty state and compute final render set.
44+
*
45+
* @param ChangeDetectionResult $changes Initial change detection
46+
* @param DependencyGraph $graph Dependency relationships
47+
* @param array<string, DocumentExports> $oldExports Previous build's exports
48+
* @param array<string, DocumentExports> $newExports Current build's exports (for dirty docs)
49+
*/
50+
public function propagate(
51+
ChangeDetectionResult $changes,
52+
DependencyGraph $graph,
53+
array $oldExports,
54+
array $newExports,
55+
): PropagationResult {
56+
// Start with directly dirty/new documents
57+
$dirtySet = array_flip(array_merge($changes->dirty, $changes->new));
58+
$propagatedFrom = [];
59+
60+
// Handle deleted files - their dependents become dirty
61+
foreach ($changes->deleted as $deletedPath) {
62+
$dependents = $graph->getDependents($deletedPath);
63+
foreach ($dependents as $dependent) {
64+
if (isset($dirtySet[$dependent])) {
65+
continue;
66+
}
67+
68+
$dirtySet[$dependent] = true;
69+
$propagatedFrom[] = $deletedPath;
70+
}
71+
}
72+
73+
// Check if exports changed for dirty docs
74+
// If so, propagate to dependents
75+
/** @var SplQueue<string> $queue */
76+
$queue = new SplQueue();
77+
foreach (array_keys($dirtySet) as $doc) {
78+
$queue->enqueue($doc);
79+
}
80+
81+
$visited = [];
82+
83+
while (!$queue->isEmpty()) {
84+
$current = $queue->dequeue();
85+
86+
if (isset($visited[$current])) {
87+
continue;
88+
}
89+
90+
$visited[$current] = true;
91+
92+
// Defense-in-depth: prevent runaway propagation
93+
if (count($visited) >= self::MAX_PROPAGATION_VISITS) {
94+
break;
95+
}
96+
97+
// Check if exports changed
98+
$old = $oldExports[$current] ?? null;
99+
$new = $newExports[$current] ?? null;
100+
101+
$exportsChanged = false;
102+
if ($old === null || $new === null) {
103+
// New or deleted - definitely changed
104+
$exportsChanged = true;
105+
} elseif ($old->hasExportsChanged($new)) {
106+
$exportsChanged = true;
107+
}
108+
109+
if (!$exportsChanged) {
110+
continue;
111+
}
112+
113+
// Propagate to dependents
114+
foreach ($graph->getDependents($current) as $dependent) {
115+
if (isset($dirtySet[$dependent])) {
116+
continue;
117+
}
118+
119+
$dirtySet[$dependent] = true;
120+
$propagatedFrom[] = $current;
121+
122+
// Add to queue for further propagation
123+
if (isset($visited[$dependent])) {
124+
continue;
125+
}
126+
127+
$queue->enqueue($dependent);
128+
}
129+
}
130+
131+
// Compute final sets
132+
$documentsToRender = array_keys($dirtySet);
133+
$documentsToSkip = array_diff($changes->clean, $documentsToRender);
134+
135+
return new PropagationResult(
136+
documentsToRender: array_values($documentsToRender),
137+
documentsToSkip: array_values($documentsToSkip),
138+
propagatedFrom: array_unique($propagatedFrom),
139+
);
140+
}
141+
142+
/**
143+
* Simple propagation without export comparison.
144+
* Used when exports aren't available yet (during initial compile).
145+
*
146+
* @param string[] $dirtyDocs Initially dirty documents
147+
* @param DependencyGraph $graph Dependency relationships
148+
*
149+
* @return string[] All documents that need rendering
150+
*/
151+
public function propagateSimple(array $dirtyDocs, DependencyGraph $graph): array
152+
{
153+
return $graph->propagateDirty($dirtyDocs);
154+
}
155+
}

0 commit comments

Comments
 (0)