Skip to content

Commit f8d7d38

Browse files
committed
Reuse primary PHP also for queued requests, reuse secondary PHP insances
1 parent 3b02f72 commit f8d7d38

File tree

3 files changed

+97
-188
lines changed

3 files changed

+97
-188
lines changed

packages/php-wasm/node/src/test/php-process-manager.spec.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('PHPProcessManager', () => {
7373
);
7474
});
7575

76-
it('should not start a second PHP instance until the first getInstance() call when the primary instance is busy', async () => {
76+
it('should reuse idle instances and only spawn when needed', async () => {
7777
const phpFactory = vitest.fn(
7878
async () => new PHP(await loadNodeRuntime(RecommendedPHPVersion))
7979
);
@@ -83,39 +83,45 @@ describe('PHPProcessManager', () => {
8383
});
8484

8585
expect(phpFactory).not.toHaveBeenCalled();
86+
87+
// First acquire spawns primary instance
8688
const php1 = await mgr.acquirePHPInstance();
8789
expect(phpFactory).toHaveBeenCalledTimes(1);
8890
php1.reap();
8991

92+
// Second acquire reuses the now-idle primary
9093
const php2 = await mgr.acquirePHPInstance();
9194
expect(phpFactory).toHaveBeenCalledTimes(1);
9295
php2.reap();
9396

94-
await mgr.acquirePHPInstance();
95-
await mgr.acquirePHPInstance();
96-
expect(phpFactory).toHaveBeenCalledTimes(3);
97+
// Third acquire reuses primary again
98+
const php3 = await mgr.acquirePHPInstance();
99+
expect(phpFactory).toHaveBeenCalledTimes(1);
100+
101+
// Fourth acquire needs a new instance (primary is busy)
102+
const php4 = await mgr.acquirePHPInstance();
103+
expect(phpFactory).toHaveBeenCalledTimes(2);
104+
105+
php3.reap();
106+
php4.reap();
97107
});
98108

99-
it('should refuse to spawn two primary PHP instances', async () => {
109+
it('should not spawn duplicate primary instances when called concurrently', async () => {
110+
const phpFactory = vitest.fn(
111+
async () => new PHP(await loadNodeRuntime(RecommendedPHPVersion))
112+
);
100113
const mgr = new PHPProcessManager({
101-
phpFactory: async () =>
102-
new PHP(await loadNodeRuntime(RecommendedPHPVersion)),
114+
phpFactory,
103115
maxPhpInstances: 5,
104116
});
105117

106-
// A synchronous call. Do not await this promise on purpose.
107-
mgr.getPrimaryPhp();
108-
109-
// No await here, because we want to check if a second,
110-
// synchronous call throws an error if issued before
111-
// the first call completes asynchronously.
112-
try {
113-
mgr.getPrimaryPhp();
114-
} catch (e) {
115-
expect(e).toBeInstanceOf(Error);
116-
expect((e as Error).message).toContain(
117-
'Requested spawning a primary PHP instance'
118-
);
119-
}
118+
// Call getPrimaryPhp() twice concurrently - both should return the same instance
119+
const [php1, php2] = await Promise.all([
120+
mgr.getPrimaryPhp(),
121+
mgr.getPrimaryPhp(),
122+
]);
123+
124+
expect(php1).toBe(php2);
125+
expect(phpFactory).toHaveBeenCalledTimes(1);
120126
});
121127
});

packages/php-wasm/universal/src/lib/php-process-manager.ts

Lines changed: 69 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,18 @@ export type PHPFactory = (options: PHPFactoryOptions) => Promise<PHP>;
1010

1111
export interface ProcessManagerOptions {
1212
/**
13-
* The maximum number of PHP instances that can exist at
13+
* The maximum number of PHP instances that can be in use at
1414
* the same time.
1515
*/
1616
maxPhpInstances?: number;
1717
/**
1818
* The number of milliseconds to wait for a PHP instance when
19-
* we have reached the maximum number of PHP instances and
20-
* cannot spawn a new one. If the timeout is reached, we assume
21-
* all the PHP instances are deadlocked and a throw MaxPhpInstancesError.
19+
* all instances are busy. If the timeout is reached, we assume
20+
* all the PHP instances are deadlocked and throw MaxPhpInstancesError.
2221
*
2322
* Default: 30000
2423
*/
2524
timeout?: number;
26-
/**
27-
* The primary PHP instance that's never killed. This instance
28-
* contains the reference filesystem used by all other PHP instances.
29-
*/
30-
primaryPhp?: PHP;
3125
/**
3226
* A factory function used for spawning new PHP instances.
3327
*/
@@ -44,208 +38,117 @@ export class MaxPhpInstancesError extends Error {
4438
}
4539

4640
/**
47-
* A PHP Process manager.
48-
*
49-
* Maintains:
50-
* * A single "primary" PHP instance that's never killed – it contains the
51-
* reference filesystem used by all other PHP instances.
52-
* * A pool of disposable PHP instances that are spawned to handle a single
53-
* request and reaped immediately after.
41+
* A PHP Process manager that maintains a pool of reusable PHP instances.
5442
*
55-
* When a new request comes in, PHPProcessManager yields the idle instance to
56-
* handle it, and immediately starts initializing a new idle instance. In other
57-
* words, for n concurrent requests, there are at most n+1 PHP instances
58-
* running at the same time.
43+
* Instances are spawned on demand up to `maxPhpInstances` and reused across
44+
* requests. The first instance spawned is the "primary" instance which
45+
* contains the reference filesystem used by all other instances.
5946
*
60-
* A slight nuance is that the first idle instance is not initialized until the
61-
* first concurrent request comes in. This is because many use-cases won't
62-
* involve parallel requests and, for those, we can avoid eagerly spinning up a
63-
* second PHP instance.
64-
*
65-
* This strategy is inspired by Cowboy, an Erlang HTTP server. Handling a
66-
* single extra request can happen immediately, while handling multiple extra
67-
* requests requires extra time to spin up a few PHP instances. This is a more
68-
* resource-friendly tradeoff than keeping 5 idle instances at all times.
47+
* The semaphore controls how many requests can be processed concurrently.
48+
* When all instances are busy, new requests wait in a queue until an
49+
* instance becomes available or the timeout is reached.
6950
*/
7051
export class PHPProcessManager implements PHPInstanceManager {
71-
private primaryPhp?: PHP;
72-
private primaryPhpPromise?: Promise<AcquiredPHP>;
73-
private primaryIdle = true;
74-
private nextInstance: Promise<AcquiredPHP> | null = null;
75-
/**
76-
* All spawned PHP instances, including the primary PHP instance.
77-
* Used for bookkeeping and reaping all instances on dispose.
78-
*/
79-
private allInstances: Promise<AcquiredPHP>[] = [];
52+
/** All PHP instances that have been spawned. */
53+
private instances: PHP[] = [];
54+
/** Instances that are currently idle and available for use. */
55+
private idleInstances: PHP[] = [];
8056
private phpFactory?: PHPFactory;
8157
private maxPhpInstances: number;
8258
private semaphore: Semaphore;
59+
/** Promise guard to prevent spawning multiple primary instances. */
60+
private primaryPhpPromise?: Promise<PHP>;
8361

8462
constructor(options?: ProcessManagerOptions) {
8563
this.maxPhpInstances = options?.maxPhpInstances ?? 2;
8664
this.phpFactory = options?.phpFactory;
87-
this.primaryPhp = options?.primaryPhp;
8865
this.semaphore = new Semaphore({
8966
concurrency: this.maxPhpInstances,
90-
/**
91-
* Wait up to 5 seconds for resources to become available
92-
* before assuming that all the PHP instances are deadlocked.
93-
*/
9467
timeout: options?.timeout || 30000,
9568
});
9669
}
9770

9871
/**
99-
* Get the primary PHP instance.
100-
*
101-
* If the primary PHP instance is not set, it will be spawned
102-
* using the provided phpFactory.
103-
*
104-
* @throws {Error} when called twice before the first call is resolved.
72+
* Get the primary PHP instance (the first one spawned).
73+
* If no instance exists yet, one will be spawned.
10574
*/
106-
async getPrimaryPhp() {
107-
if (!this.phpFactory && !this.primaryPhp) {
108-
throw new Error(
109-
'phpFactory or primaryPhp must be set before calling getPrimaryPhp().'
110-
);
111-
} else if (!this.primaryPhp) {
75+
async getPrimaryPhp(): Promise<PHP> {
76+
if (this.instances.length === 0) {
11277
if (!this.primaryPhpPromise) {
113-
this.primaryPhpPromise = this.spawn({ isPrimary: true });
78+
this.primaryPhpPromise = this.spawnInstance(true);
79+
this.primaryPhpPromise.finally(() => {
80+
this.primaryPhpPromise = undefined;
81+
});
11482
}
115-
this.primaryPhp = (await this.primaryPhpPromise).php;
116-
this.primaryPhpPromise = undefined;
83+
await this.primaryPhpPromise;
11784
}
118-
return this.primaryPhp!;
85+
return this.instances[0];
11986
}
12087

12188
/**
122-
* Get a PHP instance.
89+
* Acquire a PHP instance for processing a request.
12390
*
124-
* It could be either the primary PHP instance, an idle disposable PHP
125-
* instance, or a newly spawned PHP instance – depending on the resource
126-
* availability.
91+
* Returns an idle instance from the pool, or spawns a new one if
92+
* the pool isn't at capacity. If all instances are busy, waits
93+
* until one becomes available.
12794
*
128-
* @throws {MaxPhpInstancesError} when the maximum number of PHP instances is reached
129-
* and the waiting timeout is exceeded.
95+
* @throws {MaxPhpInstancesError} when the timeout is reached waiting
96+
* for an available instance.
13097
*/
13198
async acquirePHPInstance(): Promise<AcquiredPHP> {
132-
/**
133-
* First and foremost, make sure we have the primary PHP instance in place.
134-
* We may not actually acquire it. We just need it to exist.
135-
*
136-
* @TODO: Re-evaluate why we need it to exist. Should spawn() be just more
137-
* lenient with its "another primary instance already started spawning"
138-
* check?
139-
*/
140-
if (!this.primaryPhp) {
141-
await this.getPrimaryPhp();
142-
}
143-
144-
if (this.primaryIdle) {
145-
this.primaryIdle = false;
146-
return {
147-
php: await this.getPrimaryPhp(),
148-
reap: () => {
149-
this.primaryIdle = true;
150-
},
151-
};
99+
let releaseSemaphore: () => void;
100+
try {
101+
releaseSemaphore = await this.semaphore.acquire();
102+
} catch (error) {
103+
if (error instanceof AcquireTimeoutError) {
104+
throw new MaxPhpInstancesError(this.maxPhpInstances);
105+
}
106+
throw error;
152107
}
153108

154-
/**
155-
* nextInstance is null:
156-
*
157-
* * Before the first concurrent getInstance() call
158-
* * When the last getInstance() call did not have enough
159-
* budget left to optimistically start spawning the next
160-
* instance.
161-
*/
162-
const acquiredPHP =
163-
this.nextInstance || this.spawn({ isPrimary: false });
109+
const php = await this.getOrSpawnInstance();
164110

165-
/**
166-
* Start spawning the next instance if there's still room. We can't
167-
* just always spawn the next instance because spawn() can fail
168-
* asynchronously and then we'll get an unhandled promise rejection.
169-
*/
170-
if (this.semaphore.remaining > 0) {
171-
this.nextInstance = this.spawn({ isPrimary: false });
172-
} else {
173-
this.nextInstance = null;
174-
}
175-
return await acquiredPHP;
111+
return {
112+
php,
113+
reap: () => {
114+
this.idleInstances.push(php);
115+
releaseSemaphore();
116+
},
117+
};
176118
}
177119

178120
/**
179-
* Initiated spawning of a new PHP instance.
180-
* This function is synchronous on purpose – it needs to synchronously
181-
* add the spawn promise to the allInstances array without waiting
182-
* for PHP to spawn.
121+
* Get an idle instance or spawn a new one if under capacity.
183122
*/
184-
private spawn(factoryArgs: PHPFactoryOptions): Promise<AcquiredPHP> {
185-
if (factoryArgs.isPrimary && this.allInstances.length > 0) {
186-
throw new Error(
187-
'Requested spawning a primary PHP instance when another primary instance already started spawning.'
188-
);
123+
private async getOrSpawnInstance(): Promise<PHP> {
124+
if (this.idleInstances.length > 0) {
125+
return this.idleInstances.pop()!;
189126
}
190-
const spawned = this.doSpawn(factoryArgs);
191-
this.allInstances.push(spawned);
192-
const pop = () => {
193-
this.allInstances = this.allInstances.filter(
194-
(instance) => instance !== spawned
195-
);
196-
};
197-
return spawned
198-
.catch((rejection) => {
199-
pop();
200-
throw rejection;
201-
})
202-
.then((result) => ({
203-
...result,
204-
reap: () => {
205-
pop();
206-
result.reap();
207-
},
208-
}));
127+
if (this.instances.length === 0) {
128+
return await this.getPrimaryPhp();
129+
}
130+
return await this.spawnInstance(false);
209131
}
210132

211133
/**
212-
* Actually acquires the lock and spawns a new PHP instance.
134+
* Spawn a new PHP instance.
213135
*/
214-
private async doSpawn(
215-
factoryArgs: PHPFactoryOptions
216-
): Promise<AcquiredPHP> {
217-
let release: () => void;
218-
try {
219-
release = await this.semaphore.acquire();
220-
} catch (error) {
221-
if (error instanceof AcquireTimeoutError) {
222-
throw new MaxPhpInstancesError(this.maxPhpInstances);
223-
}
224-
throw error;
225-
}
226-
try {
227-
const php = await this.phpFactory!(factoryArgs);
228-
return {
229-
php,
230-
reap() {
231-
php.exit();
232-
release();
233-
},
234-
};
235-
} catch (e) {
236-
release();
237-
throw e;
136+
private async spawnInstance(isPrimary: boolean): Promise<PHP> {
137+
if (!this.phpFactory) {
138+
throw new Error(
139+
'phpFactory must be set before spawning instances.'
140+
);
238141
}
142+
const php = await this.phpFactory({ isPrimary });
143+
this.instances.push(php);
144+
return php;
239145
}
240146

241147
async [Symbol.asyncDispose]() {
242-
if (this.primaryPhp) {
243-
this.primaryPhp.exit();
148+
for (const php of this.instances) {
149+
php.exit();
244150
}
245-
await Promise.all(
246-
this.allInstances.map((instance) =>
247-
instance.then(({ reap }) => reap())
248-
)
249-
);
151+
this.instances = [];
152+
this.idleInstances = [];
250153
}
251154
}

packages/playground/wordpress/src/boot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) {
474474
/**
475475
* If maxPhpInstances is not 1, the PHPRequestHandler constructor needs
476476
* a PHP factory function. Internally, it creates a PHPProcessManager that
477-
* dynamically starts new PHP instances and reaps them after they're used.
477+
* maintains a pool of reusable PHP instances.
478478
*/
479479
phpFactory:
480480
options.maxPhpInstances !== 1

0 commit comments

Comments
 (0)