Refactor site watchers into unified _watch CLI command - #2380
Conversation
38b8247 to
34c722c
Compare
36fab92 to
2f32c7b
Compare
…n to CREATED events
fredrikekelund
left a comment
There was a problem hiding this comment.
Submitting my review in pieces… First batch comes here.
I spent some time experimenting with a different pattern than the events-relay pm2 process. The current pattern works, but I agree with you that it's not ideal. Using a socket as an event emitter between processes would be much nicer, but I couldn't get that to work using pm2's built-in socket…
| export const SITE_EVENTS = { | ||
| CREATED: 'site-created', | ||
| UPDATED: 'site-updated', | ||
| DELETED: 'site-deleted', | ||
| } as const; | ||
|
|
||
| export const siteEventSchema = z.object( { | ||
| event: z.string(), |
There was a problem hiding this comment.
| export const SITE_EVENTS = { | |
| CREATED: 'site-created', | |
| UPDATED: 'site-updated', | |
| DELETED: 'site-deleted', | |
| } as const; | |
| export const siteEventSchema = z.object( { | |
| event: z.string(), | |
| export enum SITE_EVENTS { | |
| CREATED = 'site-created', | |
| UPDATED = 'site-updated', | |
| DELETED = 'site-deleted', | |
| } | |
| export const siteEventSchema = z.object( { | |
| event: z.nativeEnum( SITE_EVENTS ), |
We'd benefit from stricter types here, and this is a pretty straightforward way to achieve it.
| * @param data - The event data (must include siteId) | ||
| */ | ||
| export async function emitSiteEvent( | ||
| event: string, |
There was a problem hiding this comment.
| event: string, | |
| event: SITE_EVENTS, |
Let's make the type more strict.
| if ( processName !== relayProcessName ) { | ||
| return; | ||
| } | ||
| if ( LIFECYCLE_EVENTS.includes( topic ) ) { |
There was a problem hiding this comment.
| if ( LIFECYCLE_EVENTS.includes( topic ) ) { | |
| if ( | |
| topic === SITE_EVENTS.UPDATED || | |
| topic === SITE_EVENTS.DELETED || | |
| topic === SITE_EVENTS.CREATED | |
| ) { |
An example of how we might handle stricter types after converting SITE_EVENTS to an enum
| export async function emitSiteEvent( | ||
| event: string, | ||
| data: { siteId: string; url?: string } | ||
| ): Promise< void > { | ||
| const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME ); | ||
| if ( ! relayProcess?.pm_id ) { | ||
| // No relay running - that's fine, Studio might not be open | ||
| return; | ||
| } | ||
|
|
||
| return new Promise( ( resolve ) => { | ||
| pm2.sendDataToProcessId( | ||
| relayProcess.pm_id as number, | ||
| { | ||
| type: 'process:msg', | ||
| topic: event, | ||
| data, | ||
| }, | ||
| () => { | ||
| resolve(); | ||
| } | ||
| ); | ||
| } ); | ||
| } |
There was a problem hiding this comment.
I understand the reason for the current strategy of using a pm2-managed events-relay process to be that we need a pm2 process ID to be able to use the pm2.sendDataToProcessId API.
I spent some time trying a different strategy: interacting directly with the pm2 "bus". The bus is an instance of sub-socket that comes from the pm2-axon module. Unfortunately, that module is old and seemingly unmaintained… Despite multiple attempts, I couldn't manage to get a simple pub/sub pattern (listening on a Unix socket) to work.
If it had worked, I would argue it's a better pattern, but this relay-based solution is an OK option within our current constraints, IMO.
| const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME ); | ||
| if ( ! relayProcess?.pm_id ) { | ||
| // No relay running - that's fine, Studio might not be open | ||
| return; | ||
| } | ||
|
|
||
| return new Promise( ( resolve ) => { | ||
| pm2.sendDataToProcessId( | ||
| relayProcess.pm_id as number, |
There was a problem hiding this comment.
| const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME ); | |
| if ( ! relayProcess?.pm_id ) { | |
| // No relay running - that's fine, Studio might not be open | |
| return; | |
| } | |
| return new Promise( ( resolve ) => { | |
| pm2.sendDataToProcessId( | |
| relayProcess.pm_id as number, | |
| const relayProcess = await isProcessRunning( EVENTS_RELAY_PROCESS_NAME ); | |
| if ( ! relayProcess?.pmId ) { | |
| // No relay running - that's fine, Studio might not be open | |
| return; | |
| } | |
| return new Promise( ( resolve ) => { | |
| pm2.sendDataToProcessId( | |
| relayProcess.pmId, |
isProcessRunning is really a misnomer… It already does the same thing as getProcessByName
| async function getProcessByName( name: string ): Promise< { pm_id: number | undefined } | null > { | ||
| return new Promise( ( resolve, reject ) => { | ||
| pm2.list( ( error, processes ) => { | ||
| if ( error ) { | ||
| reject( error ); | ||
| return; | ||
| } | ||
| const proc = ( processes || [] ).find( ( p ) => p.name === name ); | ||
| resolve( proc ? { pm_id: proc.pm_id } : null ); | ||
| } ); | ||
| } ); | ||
| } | ||
|
|
There was a problem hiding this comment.
| async function getProcessByName( name: string ): Promise< { pm_id: number | undefined } | null > { | |
| return new Promise( ( resolve, reject ) => { | |
| pm2.list( ( error, processes ) => { | |
| if ( error ) { | |
| reject( error ); | |
| return; | |
| } | |
| const proc = ( processes || [] ).find( ( p ) => p.name === name ); | |
| resolve( proc ? { pm_id: proc.pm_id } : null ); | |
| } ); | |
| } ); | |
| } |
isProcessRunning already does the same thing
| export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > { | ||
| const existingProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME ); | ||
| if ( existingProcess?.pm_id !== undefined ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
| export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > { | |
| const existingProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME ); | |
| if ( existingProcess?.pm_id !== undefined ) { | |
| return; | |
| } | |
| export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > { | |
| const existingProcess = await isProcessRunning( EVENTS_RELAY_PROCESS_NAME ); | |
| if ( existingProcess?.pmId !== undefined ) { | |
| return; | |
| } |
fredrikekelund
left a comment
There was a problem hiding this comment.
I'm nearly done with the code review
| export const registerCommand = ( yargs: StudioArgv ) => { | ||
| return yargs.command( { | ||
| command: '_events', | ||
| describe: false, // Hidden command | ||
| handler: async () => { | ||
| try { | ||
| await runCommand(); | ||
| } catch ( error ) { | ||
| if ( error instanceof LoggerError ) { | ||
| logger.reportError( error ); | ||
| } else { | ||
| const loggerError = new LoggerError( __( 'Events watcher failed' ), error ); | ||
| logger.reportError( loggerError ); | ||
| } | ||
| } | ||
| }, | ||
| } ); | ||
| }; |
There was a problem hiding this comment.
| export const registerCommand = ( yargs: StudioArgv ) => { | |
| return yargs.command( { | |
| command: '_events', | |
| describe: false, // Hidden command | |
| handler: async () => { | |
| try { | |
| await runCommand(); | |
| } catch ( error ) { | |
| if ( error instanceof LoggerError ) { | |
| logger.reportError( error ); | |
| } else { | |
| const loggerError = new LoggerError( __( 'Events watcher failed' ), error ); | |
| logger.reportError( loggerError ); | |
| } | |
| } | |
| }, | |
| } ); | |
| }; | |
| export async function commandHandler() { | |
| try { | |
| await runCommand(); | |
| } catch ( error ) { | |
| if ( error instanceof LoggerError ) { | |
| logger.reportError( error ); | |
| } else { | |
| const loggerError = new LoggerError( __( 'Events watcher failed' ), error ); | |
| logger.reportError( loggerError ); | |
| } | |
| } | |
| } |
This is just a recommendation, but cli/commands/wp.ts has already established this pattern for running top-level commands.
It mostly just comes down to cli/index.ts being easier to ready with this approach, since it allows us to keep chaining .command() calls, and all top-level command names are visible in that file.
On the other hand, the registerCommand pattern is also well established, so I could be swayed in that direction, too…
| .demandCommand( 1, __( 'You must provide a valid command' ) ) | ||
| .strict(); | ||
|
|
||
| registerEventsCommand( studioArgv ); | ||
|
|
||
| await studioArgv.argv; |
There was a problem hiding this comment.
| .demandCommand( 1, __( 'You must provide a valid command' ) ) | |
| .strict(); | |
| registerEventsCommand( studioArgv ); | |
| await studioArgv.argv; | |
| .command( { | |
| command: '_events', | |
| describe: false, // Hidden command | |
| handler: _eventsCommandHandler, | |
| } ) | |
| .demandCommand( 1, __( 'You must provide a valid command' ) ) | |
| .strict(); | |
| await studioArgv.argv; |
Per my recommendation of exporting a commandHandler function from cli/commands/_events instead of a registerCommand callback.
| @@ -450,7 +451,8 @@ export async function subscribeSiteEvents( | |||
| let debounceTimeout: NodeJS.Timeout | null = null; | |||
There was a problem hiding this comment.
It looks like we aren't using the debounceMs option anywhere. Can we remove the debounce logic and simplify invokeHandler?
There was a problem hiding this comment.
It looks like we can undo this change. The test still passes with the old code when I try it locally.
| async function handleSiteEvent( event: SiteEvent ): Promise< void > { | ||
| const { event: eventType, siteId, site, running } = event; | ||
| const previous = pendingUpdates.get( siteId ) ?? Promise.resolve(); | ||
| const current = previous | ||
| .catch( () => {} ) | ||
| .then( () => { |
There was a problem hiding this comment.
| async function handleSiteEvent( event: SiteEvent ): Promise< void > { | |
| const { event: eventType, siteId, site, running } = event; | |
| const previous = pendingUpdates.get( siteId ) ?? Promise.resolve(); | |
| const current = previous | |
| .catch( () => {} ) | |
| .then( () => { | |
| const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > => { | |
| const { event: eventType, siteId, site, running } = event; |
The common/lib/sequential.ts helper could be used here. I know the current logic sets up separate queues for each site, but since the event handler isn't actually executing any async logic, we still achieve the same effect with the non-partitioned sequential queue.
| if ( childProcess.connected ) { | ||
| childProcess.disconnect(); | ||
| } |
There was a problem hiding this comment.
| if ( childProcess.connected ) { | |
| childProcess.disconnect(); | |
| } |
We don't need this.
| globalShortcut.unregisterAll(); | ||
| stopUserDataWatcher(); | ||
| stopSiteWatcher(); | ||
| void stopCliEventsSubscriber(); |
There was a problem hiding this comment.
| void stopCliEventsSubscriber(); | |
| stopCliEventsSubscriber(); |
It's not an async function
| function emitSiteEvent( event: string, siteId: string ): void { | ||
| const existing = pendingEmits.get( siteId ); | ||
|
|
||
| if ( event === SITE_EVENTS.DELETED ) { | ||
| if ( existing ) { | ||
| clearTimeout( existing.timeout ); | ||
| pendingEmits.delete( siteId ); | ||
| } | ||
| void doEmitSiteEvent( event, siteId ); | ||
| return; | ||
| } | ||
|
|
||
| let effectiveEvent = event; | ||
| if ( existing ) { | ||
| clearTimeout( existing.timeout ); | ||
| if ( existing.event === SITE_EVENTS.CREATED ) { | ||
| effectiveEvent = SITE_EVENTS.CREATED; | ||
| } | ||
| } | ||
|
|
||
| const timeout = setTimeout( () => { | ||
| pendingEmits.delete( siteId ); | ||
| void doEmitSiteEvent( effectiveEvent, siteId ); | ||
| }, DEBOUNCE_MS ); | ||
|
|
||
| pendingEmits.set( siteId, { event: effectiveEvent, timeout } ); | ||
| } |
There was a problem hiding this comment.
Do we really need to be this clever..? Can't we just use the common/lib/sequential.ts helper to ensure that events are sent in the order they're received?
Related issues
Proposed Changes
This PR refactors site watching into a unified architecture using a hidden
_eventsCLI command that streams site events to Studio:studio _events): Streams site lifecycle events (CREATED, UPDATED, DELETED) to Studio via IPC messagescli/events-relay.ts): PM2-managed process that watches appdata file changes and emits eventscommon/lib/site-events.ts): Shared schema for site events between CLI and appcli-events-subscriber.ts): Subscribes to_eventscommand output and updates SiteServer instancesArchitecture:
Key improvements:
Testing Instructions
Site Status Changes via CLI
studio site start <site-name>- verify UI updatesstudio site stop <site-name>- verify UI updatesSite Creation via CLI
studio site create --name "CLI Test Site" --path ~/Sites/cli-testSite Editing via CLI
studio site set --name "CLI Test Site" --php-version 8.2Site Deletion via CLI
studio site delete --name "CLI Test Site"App Operations
Edge Cases
Pre-merge Checklist