44
55namespace Netlogix \JobQueue \Scheduled \Command ;
66
7- use Doctrine \DBAL \Exception \ConnectionLost ;
8- use Doctrine \ORM \EntityManagerInterface ;
7+ use Doctrine \DBAL \Types \Types ;
98use Flowpack \JobQueue \Common \Job \JobManager ;
10- use Flowpack \JobQueue \Common \Queue \FakeQueue ;
11- use Flowpack \JobQueue \Common \Queue \Message ;
12- use Neos \Flow \Annotations as Flow ;
139use Neos \Flow \Cli \CommandController ;
1410use Neos \Flow \Log \ThrowableStorageInterface ;
15- use Netlogix \JobQueue \Scheduled \ AsScheduledJob \ SchedulingInformation ;
11+ use Netlogix \JobQueue \Pool \ Pool ;
1612use Netlogix \JobQueue \Scheduled \Domain \Model \ScheduledJob ;
1713use Netlogix \JobQueue \Scheduled \Domain \SchedulingCoordinator ;
1814use Netlogix \JobQueue \Scheduled \Domain \Scheduler ;
15+ use Netlogix \JobQueue \Scheduled \Service \Connection ;
1916
20- /**
21- * @Flow\Scope("singleton")
22- */
2317class SchedulerCommandController extends CommandController
2418{
2519 private const TEN_MINUTES_IN_SECONDS = 600 ;
2620
27- protected $ stopPollingAfter = self :: TEN_MINUTES_IN_SECONDS ;
21+ protected Scheduler $ scheduler ;
2822
29- /**
30- * @var Scheduler
31- */
32- protected $ scheduler ;
23+ protected JobManager $ jobManager ;
3324
34- /**
35- * @var JobManager
36- */
37- protected $ jobManager ;
25+ protected ThrowableStorageInterface $ throwableStorage ;
3826
39- /**
40- * @var ThrowableStorageInterface
41- */
42- protected $ throwableStorage ;
27+ protected Connection $ dbal ;
4328
4429 public function injectScheduler (Scheduler $ scheduler ): void
4530 {
@@ -56,93 +41,151 @@ public function injectThrowableStorageInterface(ThrowableStorageInterface $throw
5641 $ this ->throwableStorage = $ throwableStorage ;
5742 }
5843
59- public function injectEntityManager ( EntityManagerInterface $ entityManager ): void
44+ public function injectConnection ( Connection $ connection ): void
6045 {
61- /*
62- * Find a better way to keep connections open. This might only work for MySQL.
63- */
64- $ entityManager
65- ->getConnection ()
66- ->exec ('SET SESSION wait_timeout = 3600; ' );
46+ $ this ->dbal = $ connection ;
6747 }
6848
6949 /**
70- * Fetch due jobs and schedule them for execution.
50+ * Reset stale jobs that have not changed for too long.
51+ *
52+ * @param string $groupName Free jobs in this group only
53+ * @param int $minutes Count jobs as stale if their last activity was more than these many minutes ago
7154 */
72- public function queueDueJobsCommand (string $ groupName ): void
73- {
74- $ this ->queueDueJobs ($ groupName );
55+ public function resetStaleJobsCommand (
56+ string $ groupName ,
57+ int $ minutes = 10
58+ ): void {
59+ $ tableName = ScheduledJob::TABLE_NAME ;
60+ $ freed = $ this ->dbal ->executeQuery (
61+ sql: <<<MySQL
62+ UPDATE {$ tableName }
63+ SET running = 0,
64+ claimed = '',
65+ incarnation = incarnation + 1
66+ WHERE running = 1
67+ AND claimed NOT LIKE 'failed(%)'
68+ AND groupname = :groupName
69+ AND activity < NOW() - INTERVAL :minutes MINUTE
70+ MySQL ,
71+ params: [
72+ 'groupName ' => $ groupName ,
73+ 'minutes ' => max ($ minutes , 1 ),
74+ ],
75+ types: [
76+ 'groupName ' => Types::STRING ,
77+ 'minutes ' => Types::SMALLINT ,
78+ ],
79+ )->rowCount ();
80+
81+ if ($ freed ) {
82+ $ this ->outputLine ('Freed ' . $ freed . ' stale jobs. ' );
83+ }
7584 }
7685
7786 /**
7887 * Fetch due jobs and schedule them, then wait and retry.
7988 * This is probably not the best way of polling for changes
89+ *
90+ * @param string $groupName Handle jobs in this group only
91+ * @param bool $outputResults Write child process output to the console
92+ * @param int $parallel Number of jobs to handle in parallel
93+ * @param int $preforkSize Number of jobs to already boot up without having a job waiting
94+ * @param int $stopPollingAfter Stop polling after this many seconds
95+ * @param float $pollingIntervalInSeconds How often to check for new jobs in seconds
8096 */
81- public function pollForIncomingJobsCommand (string $ groupName ): void
82- {
83- $ startTime = time ();
84- $ endTime = $ startTime + $ this ->stopPollingAfter ;
85-
86- while (true ) {
87- $ numberOfHandledJobs = $ this ->queueDueJobs ($ groupName , $ endTime );
88- if (time () >= $ endTime ) {
89- return ;
90- }
91- if ($ numberOfHandledJobs === 0 ) {
92- sleep (1 );
93- }
94- }
97+ public function pollForIncomingJobsCommand (
98+ string $ groupName ,
99+ bool $ outputResults = false ,
100+ int $ parallel = 1 ,
101+ int $ preforkSize = 0 ,
102+ int $ stopPollingAfter = self ::TEN_MINUTES_IN_SECONDS ,
103+ float $ pollingIntervalInSeconds = 0.1
104+ ): void {
105+ $ parallel = max ($ parallel , 1 );
106+ Pool::create (
107+ outputResults: $ outputResults ,
108+ preforkSize: $ preforkSize
109+ )
110+ ->runLoop (function (Pool $ pool ) use ($ groupName , $ parallel , $ stopPollingAfter , $ pollingIntervalInSeconds , &$ queueDueJobs ): void {
111+ // Check for new jobs in the database and schedule as much as the pool has capacity for
112+ $ queueDueJobs = $ pool ->eventLoop ->addPeriodicTimer (
113+ interval: $ pollingIntervalInSeconds ,
114+ callback: function () use ($ pool , $ groupName , $ parallel ) {
115+ $ this ->queueDueJobs (pool: $ pool , groupName: $ groupName , parallel: $ parallel );
116+ }
117+ );
118+
119+ // Keep the database connection alive
120+ $ ping = $ pool ->eventLoop ->addPeriodicTimer (
121+ interval: 30 ,
122+ callback: function () use (&$ ping ) {
123+ $ this ->scheduler ->ping ();
124+ }
125+ );
126+
127+ // Once the timeout is reached, wait until the final jobs are done and stop the loop
128+ if ($ stopPollingAfter ) {
129+ $ pool ->eventLoop ->addTimer (
130+ interval: $ stopPollingAfter ,
131+ callback: function () use ($ pool , $ queueDueJobs , $ ping ) {
132+ $ pool ->eventLoop ->cancelTimer ($ queueDueJobs );
133+ $ checkForPoolToClear = $ pool ->eventLoop ->addPeriodicTimer (
134+ interval: 1 ,
135+ callback: function () use ($ pool , $ ping , &$ checkForPoolToClear ) {
136+ if (count ($ pool ) === 0 ) {
137+ $ pool ->eventLoop ->cancelTimer ($ ping );
138+ $ pool ->eventLoop ->cancelTimer ($ checkForPoolToClear );
139+ }
140+ }
141+ );
142+ }
143+ );
144+ }
145+ });
95146 }
96147
97148 /**
149+ * @param Pool $pool A pool of subprocesses waiting for new jobs to execute
98150 * @param string $groupName Handle only jobs of this group
99- * @param int $endTime Stop handling new jobs once this time is reached
151+ * @param int $parallel Number of jobs to handle in parallel
100152 * @return int Number of handled jobs
101- * @throws ConnectionLost
102153 */
103- protected function queueDueJobs (string $ groupName , int $ endTime ): int
154+ protected function queueDueJobs (Pool $ pool , string $ groupName , int $ parallel ): int
104155 {
105156 $ numberOfHandledJobs = 0 ;
106157 $ retry = new SchedulingCoordinator ($ this ->scheduler );
107158
108- while ($ next = $ this ->scheduler ->next ($ groupName )) {
109- try {
110- if ($ next ->getQueueName () === SchedulingInformation::QUEUE_NAME ) {
111- $ this ->executeLocally ($ next );
112- } else {
113- $ this ->executeInQueue ($ next );
114- }
115- $ numberOfHandledJobs ++;
116- $ this ->scheduler ->release ($ next );
117- } catch (ConnectionLost $ e ) {
118- // Assuming we're running as supervisor process: Kill and restart
119- // The task in question might be stuck because they are claimed and we cannot release them
120- throw $ e ;
121- } catch (\Throwable $ throwable ) {
122- $ this ->throwableStorage ->logThrowable ($ throwable );
123- $ retry ->markJobForRescheduling ($ next );
124- }
125- if (time () >= $ endTime ) {
159+ while (count ($ pool ) < $ parallel ) {
160+ $ next = $ this ->scheduler ->next ($ groupName );
161+
162+ if (!$ next ) {
126163 return $ numberOfHandledJobs ;
127164 }
128- }
129165
130- $ retry ->scheduleAll ();
131- return $ numberOfHandledJobs ;
132- }
166+ $ numberOfHandledJobs ++;
133167
134- protected function executeLocally (ScheduledJob $ scheduledJob ): void
135- {
136- $ job = $ scheduledJob ->getJob ();
137- $ message = new Message ('3429a80d-1c21-433d-8d9f-82468b53fb2b ' , $ job , 0 );
138- $ job ->execute (new FakeQueue (SchedulingInformation::QUEUE_NAME ), $ message );
139- }
168+ $ process = $ pool ->runPayload (payload: $ next ->getSerializedJob (), queueName: $ next ->getQueueName ());
140169
141- protected function executeInQueue (ScheduledJob $ next ): void
142- {
143- $ this ->jobManager ->queue (
144- $ next ->getQueueName (),
145- $ next ->getJob ()
146- );
170+ $ ping = $ pool ->eventLoop ->addPeriodicTimer (
171+ interval: 1 ,
172+ callback: function () use ($ process , $ next ) {
173+ if ($ process ->isRunning ()) {
174+ $ this ->scheduler ->activity ($ next );
175+ }
176+ }
177+ );
178+
179+ $ process ->on (Pool::EVENT_EXIT , function () use ($ pool , $ retry , $ ping , &$ numberOfHandledJobs ) {
180+ $ pool ->eventLoop ->cancelTimer ($ ping );
181+ $ numberOfHandledJobs --;
182+ if ($ numberOfHandledJobs === 0 ) {
183+ ($ numberOfHandledJobs === 0 ) && $ retry ->scheduleAll ();
184+ }
185+ });
186+ $ process ->on (Pool::EVENT_SUCCESS , fn () => $ this ->scheduler ->release ($ next ));
187+ $ process ->on (Pool::EVENT_ERROR , fn () => $ retry ->markJobForRescheduling ($ next ));
188+ }
189+ return $ numberOfHandledJobs ;
147190 }
148191}
0 commit comments