├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── haystack.php ├── database ├── factories │ ├── HaystackBaleFactory.php │ ├── HaystackDataFactory.php │ └── HaystackFactory.php └── migrations │ ├── create_haystack_bales_table.php.stub │ ├── create_haystack_data_table.php.stub │ └── create_haystacks_table.php.stub ├── phpunit.xml └── src ├── Builders └── HaystackBuilder.php ├── Casts ├── CallbackCollectionCast.php ├── MiddlewareCollectionCast.php ├── SerializeClosure.php ├── Serialized.php └── SerializedModel.php ├── ChunkableHaystackJob.php ├── Concerns ├── ManagesBales.php └── Stackable.php ├── Console └── Commands │ ├── HaystackInstall.php │ ├── HaystacksClear.php │ ├── HaystacksForget.php │ └── HaystacksResume.php ├── Contracts └── StackableJob.php ├── Data ├── CallbackCollection.php ├── HaystackOptions.php ├── MiddlewareCollection.php ├── NextJob.php ├── PendingData.php ├── PendingHaystackBale.php └── SerializedModel.php ├── Enums └── FinishStatus.php ├── Exceptions └── HaystackModelExists.php ├── HaystackServiceProvider.php ├── Helpers ├── CarbonHelper.php ├── ClosureHelper.php ├── DataHelper.php ├── DataValidator.php ├── ExceptionHelper.php └── SerializationHelper.php ├── JobEventListener.php ├── Middleware ├── CheckAttempts.php ├── CheckFinished.php └── IncrementAttempts.php └── Models ├── Haystack.php ├── HaystackBale.php └── HaystackData.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__, 6 | ]) 7 | ->name('*.php') 8 | ->notName('*.blade.php') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true) 11 | ->exclude(['vendor', 'node_modules']); 12 | 13 | $config = new PhpCsFixer\Config(); 14 | 15 | // Rules from: https://cs.symfony.com/doc/rules/index.html 16 | 17 | return $config->setRules([ 18 | '@PSR2' => true, 19 | 'array_syntax' => ['syntax' => 'short'], 20 | 'ordered_imports' => ['sort_algorithm' => 'length'], 21 | 'no_unused_imports' => true, 22 | 'not_operator_with_successor_space' => true, 23 | 'trailing_comma_in_multiline' => true, 24 | 'single_quote' => ['strings_containing_single_quote_chars' => true], 25 | 'phpdoc_scalar' => true, 26 | 'unary_operator_spaces' => true, 27 | 'binary_operator_spaces' => true, 28 | 'blank_line_before_statement' => [ 29 | 'statements' => ['declare', 'return', 'throw', 'try'], 30 | ], 31 | 'phpdoc_single_line_var_spacing' => true, 32 | 'phpdoc_var_without_name' => true, 33 | 'method_argument_space' => [ 34 | 'on_multiline' => 'ensure_fully_multiline', 35 | 'keep_multiple_spaces_after_comma' => true, 36 | ], 37 | 'return_type_declaration' => [ 38 | 'space_before' => 'none' 39 | ], 40 | 'declare_strict_types' => true, 41 | 'blank_line_after_opening_tag' => true, 42 | 'single_import_per_statement' => true, 43 | 'mb_str_functions' => true, 44 | 'no_superfluous_phpdoc_tags' => true, 45 | 'no_blank_lines_after_phpdoc' => true, 46 | 'no_empty_phpdoc' => true, 47 | 'phpdoc_trim' => true, 48 | ])->setFinder($finder); 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-haystack` will be documented in this file. 4 | 5 | ## Version v0.10.1 - 2022-10-30 6 | 7 | ### What's Changed 8 | 9 | - Fix typo command output on clearing haystacks by @faisuc in https://github.com/Sammyjo20/laravel-haystack/pull/58 10 | 11 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.10.0...v0.10.1 12 | 13 | ## Version v0.10.0 - 2022-10-07 14 | 15 | ### What's Changed 16 | 17 | - Feature | Multiple Callbacks & Middleware by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/48 18 | 19 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.9.0...v0.10.0 20 | 21 | ## Version v0.9.0 - 2022-10-04 22 | 23 | ### What's Changed 24 | 25 | - Fix | Removed Spatie Laravel Package Tools by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/47 26 | 27 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.5...v0.9.0 28 | 29 | ## Version v0.8.5 - 2022-10-04 30 | 31 | - Updated docs 32 | 33 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.4...v0.8.5 34 | 35 | ## Version v0.8.4 - 2022-10-01 36 | 37 | ### What's Changed 38 | 39 | - Feature | Configured install command by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/46 40 | 41 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.3...v0.8.4 42 | 43 | ## Version v0.8.3 - 2022-10-01 44 | 45 | ### What's Changed 46 | 47 | - Feature | Models on Haystacks by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/45 48 | 49 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.2...v0.8.3 50 | 51 | ## Version v0.8.2 - 2022-09-29 52 | 53 | ### What's Changed 54 | 55 | - Feature | Allow Failures Improvements by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/43 56 | 57 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.1...v0.8.2 58 | 59 | ## Version v0.8.1 - 2022-09-29 60 | 61 | - Revised documentation 62 | 63 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.8.0...v0.8.1 64 | 65 | ## Version v0.8.0 - 2022-09-29 66 | 67 | ### New Features 68 | 69 | - New `allowFailures` option added when building Haystacks to continue processing the next job even if jobs fail. @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/42 70 | 71 | ### Other Changes 72 | 73 | - Switched to a new HaystackOptions class instead of multiple database options for better compatibility with future options. 74 | - Fixed an issue where timestamps weren't being set when haystack bales are added to the queue 75 | 76 | ### Breaking Changes 77 | 78 | - Added a new text "options" column to store the new HaystackOptions class. 79 | - Removed old `return_data` column on the `haystacks` table 80 | - Changed `value` column on the `haystack_data` table to a longText to support more data. 81 | 82 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.6...v0.8.0 83 | 84 | ## Version v0.7.6 - 2022-09-18 85 | 86 | ### What's Changed 87 | 88 | - Feature | Chunkable Jobs by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/40 89 | 90 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.5...v0.7.6 91 | 92 | ## Version v0.7.5 - 2022-09-11 93 | 94 | ### What's Changed 95 | 96 | - Feature | Initial Data by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/38 97 | 98 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.4...v0.7.5 99 | 100 | ## Version v0.7.4 - 2022-08-19 101 | 102 | ### What's Changed 103 | 104 | - Use conditional clauses when building haystack by @faisuc in https://github.com/Sammyjo20/laravel-haystack/pull/32 105 | 106 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.3...v0.7.4 107 | 108 | ## Version v0.7.3 - 2022-08-18 109 | 110 | ### What's Changed 111 | 112 | - Feature | Added declare strict types by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/31 113 | 114 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.2...v0.7.3 115 | 116 | ## Version v0.7.2 - 2022-08-18 117 | 118 | ### What's Changed 119 | 120 | - Add conditional objects when adding job to haystack by @faisuc in https://github.com/Sammyjo20/laravel-haystack/pull/30 121 | 122 | ### New Contributors 123 | 124 | - @faisuc made their first contribution in https://github.com/Sammyjo20/laravel-haystack/pull/30 125 | 126 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.1...v0.7.2 127 | 128 | ## Version v0.7.1 - 2022-08-10 129 | 130 | ### What's Changed 131 | 132 | - Add `haystacks:clear` and `haystacks:forget` commands by @viicslen in https://github.com/Sammyjo20/laravel-haystack/pull/29 133 | 134 | ### New Contributors 135 | 136 | - @viicslen made their first contribution in https://github.com/Sammyjo20/laravel-haystack/pull/29 137 | 138 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.7.0...v0.7.1 139 | 140 | ## Version v0.7.0 - 2022-08-09 141 | 142 | ### What's Changed 143 | 144 | - Names and appending multiple jobs by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/26 145 | 146 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.6.0...v0.7.0 147 | 148 | ## Version v0.6.0 - 2022-08-08 149 | 150 | ### What's Changed 151 | 152 | - Changed "appendToHaystackNext" to "prependToHaystack" by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/25 153 | 154 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.5.0...v0.6.0 155 | 156 | ## Version v0.5.0 - 2022-08-08 157 | 158 | ### What's Changed 159 | 160 | - Append to the next job in the haystack by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/24 161 | 162 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.4.3...v0.5.0 163 | 164 | ## Version v0.4.3 - 2022-08-07 165 | 166 | ### What's Changed 167 | 168 | - Cancelling haystacks by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/20 169 | 170 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.4.2...v0.4.3 171 | 172 | ## Version v0.4.2 - 2022-08-06 173 | 174 | ### What's Changed 175 | 176 | - Added closure check before running queries by @Sammyjo20 in https://github.com/Sammyjo20/laravel-haystack/pull/19 177 | 178 | **Full Changelog**: https://github.com/Sammyjo20/laravel-haystack/compare/v0.4.1...v0.4.2 179 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ### Notice 14/05/2024 2 | > 3 | > I am no longer going to be accepting new features for Laravel Haystack. I intend to still ensure security fixes 4 | > are made, but I feel that the project is now complete. Additionally, I feel that Laravel's job batches and 5 | > chains in Laravel 10+ are a lot more powerful and you may not need Laravel Haystack in 2024. 6 | 7 |
8 | 9 | 10 | 11 | # Laravel Haystack 12 | ⚡️ Supercharged job chains for Laravel 13 | 14 | ![Build Status](https://github.com/sammyjo20/laravel-haystack/actions/workflows/tests.yml/badge.svg) 15 | 16 | [Click here to read the documentation](https://docs.laravel-haystack.dev) 17 | 18 |
19 | 20 | Laravel Haystack provides supercharged job chains for Laravel. It comes with powerful features like delaying jobs for as long as you like, applying middleware to every job, sharing data and models between jobs and even chunking jobs. Laravel Haystack supports every queue connection/worker out of the box. (Database, Redis/Horizon, SQS). It's great if you need to queue thousands of jobs in a chain or if you are looking for features that the original Bus chain doesn't provide. 21 | 22 | ```php 23 | $haystack = Haystack::build() 24 | ->addJob(new RecordPodcast) 25 | ->addJob(new ProcessPodcast) 26 | ->addJob(new PublishPodcast) 27 | ->then(function () { 28 | // Haystack completed 29 | }) 30 | ->catch(function () { 31 | // Haystack failed 32 | }) 33 | ->finally(function () { 34 | // Always run either on success or fail. 35 | }) 36 | ->withMiddleware([ 37 | // Middleware for every job 38 | ]) 39 | ->withDelay(60) 40 | ->withModel($user) 41 | ->dispatch(); 42 | ``` 43 | 44 | #### But doesn't Laravel already have job chains? 45 | 46 | That's right! Laravel does have job chains but they have some disadvantages that you might want to think about. 47 | 48 | * They consume quite a lot of memory/data since the chain is stored inside the job. This is especially true if you are storing thousands of jobs. 49 | * They are volatile, meaning if you lose one job in the chain - you lose the whole chain. 50 | * They do not provide the `then`, `catch`, `finally` callable methods that batched jobs do. 51 | * Long delays with memory-based or SQS queue is not possible as you could lose the jobs due to expiry or if the server shuts down. 52 | * You can't share data between jobs as there is no "state" across the chain 53 | 54 | Laravel Haystack aims to solve this by storing the job chain in the database and queuing one job at a time. When the job is completed, Laravel Haystack listens out for the "job completed" event and queues the next job in the chain from the database. 55 | 56 | #### Laravel Haystack Features 57 | 58 | * Low memory consumption as one job is processed at a time and the chain is stored in the database 59 | * You can delay/release jobs for as long as you want since it will use the scheduler to restart a chain. Even if your queue driver is SQS! 60 | * It provides callback methods like `then`, `catch` and `finally`. 61 | * Global middleware that can be applied to every single job in the chain 62 | * You can store models and data that are shared with every job in the chain. 63 | * You can prepare a Haystack and dispatch it at a later time 64 | 65 | #### Use Cases 66 | 67 | * If you need to make hundreds or thousands of API calls in a row, can be combined with Spatie's Job Rate Limiter to keep track of delays and pause jobs when a rate limit is hit. 68 | * If you need to queue thousands of jobs in a chain at a time. 69 | * If you need to batch import rows of data - each row can be a haystack job (bale) and processed one at a time. While keeping important job information stored in the database. 70 | * If you need "release" times longer than 15 minutes if you are using Amazon SQS 71 | 72 | ## Installation 73 | 74 | You can install the package with Composer. **Laravel Haystack Requires Laravel 8+ and PHP 8.1** 75 | 76 | ```bash 77 | composer require sammyjo20/laravel-haystack 78 | ``` 79 | 80 | Next, just run the installation command! 81 | 82 | ```bash 83 | php artisan haystack:install 84 | ``` 85 | 86 | ## Documentation 87 | 88 | [Click here to read the documentation](https://docs.laravel-haystack.dev) 89 | 90 | ## Support Haystack's Development 91 | While I never expect anything, if you would like to support my work, you can donate to my Ko-Fi page by simply buying me a coffee or two! 92 | 93 | Buy Me a Coffee at ko-fi.com 94 | 95 | Thank you for using Laravel Haystack ❤️ 96 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sammyjo20/laravel-haystack", 3 | "description": "Supercharged job chains for Laravel", 4 | "license": "MIT", 5 | "keywords": [ 6 | "Sammyjo20", 7 | "laravel-haystack" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "Sammyjo20", 12 | "email": "29132017+Sammyjo20@users.noreply.github.com", 13 | "role": "Developer" 14 | } 15 | ], 16 | "homepage": "https://github.com/sammyjo20/laravel-haystack", 17 | "require": { 18 | "php": "^8.1", 19 | "illuminate/console": "^10.0 || ^11.7", 20 | "illuminate/contracts": "^10.0 || ^11.7", 21 | "illuminate/database": "^10.0 || ^v11.7", 22 | "illuminate/queue": "^10.0 || ^11.7", 23 | "illuminate/support": "^10.0 || ^11.7", 24 | "laravel/serializable-closure": "^1.2" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^3.5", 28 | "orchestra/testbench": "^8.0 || ^9.0", 29 | "pestphp/pest": "^v2.34", 30 | "pestphp/pest-plugin-laravel": "^1.2 || ^2.0", 31 | "sammyjo20/laravel-chunkable-jobs": "^1.1", 32 | "spatie/laravel-ray": "^1.26" 33 | }, 34 | "suggest": { 35 | "sammyjo20/laravel-chunkable-jobs": "Allows you to use the job chunking feature" 36 | }, 37 | "minimum-stability": "stable", 38 | "autoload": { 39 | "psr-4": { 40 | "Sammyjo20\\LaravelHaystack\\": "src", 41 | "Sammyjo20\\LaravelHaystack\\Database\\Factories\\": "database/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Sammyjo20\\LaravelHaystack\\Tests\\": "tests" 47 | } 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "pestphp/pest-plugin": true 52 | }, 53 | "sort-packages": true 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "Sammyjo20\\LaravelHaystack\\HaystackServiceProvider" 59 | ] 60 | } 61 | }, 62 | "scripts": { 63 | "post-autoload-dump": [ 64 | "@php ./vendor/bin/testbench package:discover --ansi" 65 | ], 66 | "fix-code": [ 67 | "./vendor/bin/php-cs-fixer fix --allow-risky=yes" 68 | ], 69 | "pstan": [ 70 | "./vendor/bin/phpstan analyse" 71 | ], 72 | "test": [ 73 | "./vendor/bin/pest" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/haystack.php: -------------------------------------------------------------------------------- 1 | true, 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Queue Haystack Jobs Automatically 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This value if set to true, will instruct Laravel Haystack to listen 26 | | out for "Stackable" jobs and automatically queue them after each 27 | | job is processed. If this value is set to false, you will need 28 | | to call "$this->nextJob" inside your jobs manually. 29 | | 30 | */ 31 | 32 | 'process_automatically' => true, 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Stale Haystacks 37 | |-------------------------------------------------------------------------- 38 | | 39 | | This value determines how long "stale" haystacks are kept for. These are 40 | | haystacks where the job that controlled them has failed without sending 41 | | the failure signal to laravel-haystack. This shouldn't happen if auto 42 | | processing has been turned on. 43 | | 44 | */ 45 | 46 | 'keep_stale_haystacks_for_days' => 3, 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Delete Finished Haystacks 51 | |-------------------------------------------------------------------------- 52 | | 53 | | This value determines if laravel-haystack should automatically delete 54 | | haystacks when they have finished processing. If this value is set 55 | | to false, make sure to use the scheduled command to clean up 56 | | old finished haystacks. 57 | | 58 | */ 59 | 60 | 'delete_finished_haystacks' => true, 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Keep Finished Haystacks For Days 65 | |-------------------------------------------------------------------------- 66 | | 67 | | This value determines how long finished haystacks will be retained for 68 | | this is only applicable if "deleted_finished_haystacks" has been disabled. 69 | | 70 | */ 71 | 72 | 'keep_finished_haystacks_for_days' => 1, 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Default Database Connection Name 77 | |-------------------------------------------------------------------------- 78 | | 79 | | Here you may specify which database connection you 80 | | use to store haystack jobs. 81 | | 82 | */ 83 | 84 | 'db_connection' => env( 85 | 'HAYSTACK_DB_CONNECTION', 86 | env('DB_CONNECTION', 'mysql') 87 | ), 88 | ]; 89 | -------------------------------------------------------------------------------- /database/factories/HaystackBaleFactory.php: -------------------------------------------------------------------------------- 1 | new HaystackOptions, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/migrations/create_haystack_bales_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->timestamps(); 15 | $table->foreignIdFor(Haystack::class)->constrained()->cascadeOnDelete(); 16 | $table->mediumText('job'); 17 | $table->bigInteger('delay')->default(0); 18 | $table->string('on_queue')->nullable(); 19 | $table->string('on_connection')->nullable(); 20 | $table->integer('attempts')->default(0); 21 | $table->integer('retry_until')->nullable(); 22 | $table->boolean('priority')->default(false); 23 | 24 | $table->index(['priority', 'id']); 25 | }); 26 | } 27 | 28 | public function down() 29 | { 30 | Schema::dropIfExists('haystack_bales'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/create_haystack_data_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->timestamps(); 15 | $table->foreignIdFor(Haystack::class)->constrained()->cascadeOnDelete(); 16 | $table->string('key'); 17 | $table->longText('value'); 18 | $table->string('cast')->nullable(); 19 | 20 | $table->unique(['haystack_id', 'key']); 21 | }); 22 | } 23 | 24 | public function down() 25 | { 26 | Schema::dropIfExists('haystack_data'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /database/migrations/create_haystacks_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name')->nullable(); 14 | $table->mediumText('callbacks')->nullable(); 15 | $table->mediumText('middleware')->nullable(); 16 | $table->text('options'); 17 | $table->timestamps(); 18 | $table->dateTime('started_at')->nullable(); 19 | $table->dateTime('resume_at')->nullable(); 20 | $table->dateTime('finished_at')->nullable(); 21 | }); 22 | } 23 | 24 | public function down() 25 | { 26 | Schema::dropIfExists('haystacks'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ./app 20 | ./src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Builders/HaystackBuilder.php: -------------------------------------------------------------------------------- 1 | jobs = new Collection; 93 | $this->initialData = new Collection; 94 | $this->callbacks = new CallbackCollection; 95 | $this->options = new HaystackOptions; 96 | $this->middleware = new MiddlewareCollection; 97 | } 98 | 99 | /** 100 | * Specify a name for the haystack. 101 | * 102 | * @return $this 103 | */ 104 | public function withName(string $name): static 105 | { 106 | $this->name = $name; 107 | 108 | return $this; 109 | } 110 | 111 | /** 112 | * Provide a closure that will run when the haystack is complete. 113 | * 114 | * @return $this 115 | * 116 | * @throws PhpVersionNotSupportedException 117 | */ 118 | public function then(Closure|callable $closure): static 119 | { 120 | $this->callbacks->addThen($closure); 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Provide a closure that will run when the haystack fails. 127 | * 128 | * @return $this 129 | * 130 | * @throws PhpVersionNotSupportedException 131 | */ 132 | public function catch(Closure|callable $closure): static 133 | { 134 | $this->callbacks->addCatch($closure); 135 | 136 | return $this; 137 | } 138 | 139 | /** 140 | * Provide a closure that will run when the haystack finishes. 141 | * 142 | * @return $this 143 | * 144 | * @throws PhpVersionNotSupportedException 145 | */ 146 | public function finally(Closure|callable $closure): static 147 | { 148 | $this->callbacks->addFinally($closure); 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Provide a closure that will run when the haystack is paused. 155 | * 156 | * @return $this 157 | * 158 | * @throws PhpVersionNotSupportedException 159 | */ 160 | public function paused(Closure|callable $closure): static 161 | { 162 | $this->callbacks->addPaused($closure); 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * Add a job to the haystack. 169 | * 170 | * @return $this 171 | */ 172 | public function addJob(StackableJob $job, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 173 | { 174 | $pendingHaystackRow = new PendingHaystackBale($job, $delayInSeconds, $queue, $connection); 175 | 176 | $this->jobs->add($pendingHaystackRow); 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * Add a job when a condition is true. 183 | * 184 | * @return $this 185 | */ 186 | public function addJobWhen(bool $condition, ...$arguments): static 187 | { 188 | return $condition === true ? $this->addJob(...$arguments) : $this; 189 | } 190 | 191 | /** 192 | * Add a job when a condition is false. 193 | * 194 | * @return $this 195 | */ 196 | public function addJobUnless(bool $condition, ...$arguments): static 197 | { 198 | return $this->addJobWhen(! $condition, ...$arguments); 199 | } 200 | 201 | /** 202 | * Add multiple jobs to the haystack at a time. 203 | * 204 | * @return $this 205 | */ 206 | public function addJobs(Collection|array $jobs, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 207 | { 208 | if (is_array($jobs)) { 209 | $jobs = collect($jobs); 210 | } 211 | 212 | $jobs = $jobs->filter(fn ($job) => $job instanceof StackableJob); 213 | 214 | foreach ($jobs as $job) { 215 | $this->addJob($job, $delayInSeconds, $queue, $connection); 216 | } 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Add jobs when a condition is true. 223 | * 224 | * @return $this 225 | */ 226 | public function addJobsWhen(bool $condition, ...$arguments): static 227 | { 228 | return $condition === true ? $this->addJobs(...$arguments) : $this; 229 | } 230 | 231 | /** 232 | * Add jobs when a condition is false. 233 | * 234 | * @return $this 235 | */ 236 | public function addJobsUnless(bool $condition, ...$arguments): static 237 | { 238 | return $this->addJobsWhen(! $condition, ...$arguments); 239 | } 240 | 241 | /** 242 | * Add a bale onto the haystack. Yee-haw! 243 | * 244 | * @alias addJob() 245 | * 246 | * @return $this 247 | */ 248 | public function addBale(StackableJob $job, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 249 | { 250 | return $this->addJob($job, $delayInSeconds, $queue, $connection); 251 | } 252 | 253 | /** 254 | * Add multiple bales onto the haystack. Yee-haw! 255 | * 256 | * @alias addJobs() 257 | * 258 | * @return $this 259 | */ 260 | public function addBales(Collection|array $jobs, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 261 | { 262 | return $this->addJobs($jobs, $delayInSeconds, $queue, $connection); 263 | } 264 | 265 | /** 266 | * Set a global delay on the jobs. 267 | * 268 | * @return $this 269 | */ 270 | public function withDelay(int $seconds): static 271 | { 272 | $this->globalDelayInSeconds = $seconds; 273 | 274 | return $this; 275 | } 276 | 277 | public function pausedUntil(DateTimeInterface $resumeAt): static 278 | { 279 | if (! $resumeAt instanceof CarbonImmutable) { 280 | $resumeAt = Carbon::parse($resumeAt)->toImmutable(); 281 | } 282 | 283 | $this->resumeAt = $resumeAt; 284 | 285 | return $this; 286 | } 287 | 288 | /** 289 | * Set a global queue for the jobs. 290 | * 291 | * @return $this 292 | */ 293 | public function onQueue(string $queue): static 294 | { 295 | $this->globalQueue = $queue; 296 | 297 | return $this; 298 | } 299 | 300 | /** 301 | * Set a global connection for the jobs. 302 | * 303 | * @return $this 304 | */ 305 | public function onConnection(string $connection): static 306 | { 307 | $this->globalConnection = $connection; 308 | 309 | return $this; 310 | } 311 | 312 | /** 313 | * Add some middleware to be merged in with every job 314 | * 315 | * @return $this 316 | * 317 | * @throws PhpVersionNotSupportedException 318 | */ 319 | public function addMiddleware(Closure|callable|array $value): static 320 | { 321 | $this->middleware->add($value); 322 | 323 | return $this; 324 | } 325 | 326 | /** 327 | * Provide data before the haystack is created. 328 | * 329 | * @return $this 330 | */ 331 | public function withData(string $key, mixed $value, ?string $cast = null): static 332 | { 333 | DataValidator::validateCast($value, $cast); 334 | 335 | $this->initialData->put($key, new PendingData($key, $value, $cast)); 336 | 337 | return $this; 338 | } 339 | 340 | /** 341 | * Store a model to be shared across all haystack jobs. 342 | * 343 | * @return $this 344 | * 345 | * @throws HaystackModelExists 346 | */ 347 | public function withModel(Model $model, ?string $key = null): static 348 | { 349 | $key = DataHelper::getModelKey($model, $key); 350 | 351 | if ($this->initialData->has($key)) { 352 | throw new HaystackModelExists($key); 353 | } 354 | 355 | $this->initialData->put($key, new PendingData($key, $model, SerializedModel::class)); 356 | 357 | return $this; 358 | } 359 | 360 | /** 361 | * Create the Haystack 362 | */ 363 | public function create(): Haystack 364 | { 365 | return DB::transaction(fn () => $this->createHaystack()); 366 | } 367 | 368 | /** 369 | * Dispatch the Haystack. 370 | */ 371 | public function dispatch(): Haystack 372 | { 373 | $haystack = $this->create(); 374 | 375 | $haystack->start(); 376 | 377 | return $haystack; 378 | } 379 | 380 | /** 381 | * Map the jobs to be ready for inserting. 382 | */ 383 | protected function prepareJobsForInsert(Haystack $haystack): array 384 | { 385 | $now = Carbon::now(); 386 | 387 | $timestamps = [ 388 | 'created_at' => $now, 389 | 'updated_at' => $now, 390 | ]; 391 | 392 | return $this->jobs->map(function (PendingHaystackBale $pendingJob) use ($haystack, $timestamps) { 393 | $hasDelay = isset($pendingJob->delayInSeconds) && $pendingJob->delayInSeconds > 0; 394 | 395 | // We'll create a dummy Haystack bale model for each row 396 | // and convert it into its attributes just for the casting. 397 | 398 | $baseAttributes = $haystack->bales()->make([ 399 | 'job' => $pendingJob->job, 400 | 'delay' => $hasDelay ? $pendingJob->delayInSeconds : $this->globalDelayInSeconds, 401 | 'on_queue' => $pendingJob->queue ?? $this->globalQueue, 402 | 'on_connection' => $pendingJob->connection ?? $this->globalConnection, 403 | ])->getAttributes(); 404 | 405 | // Next we'll merge in the timestamps 406 | 407 | return array_merge($timestamps, $baseAttributes); 408 | })->toArray(); 409 | } 410 | 411 | /** 412 | * Map the initial data to be ready for inserting. 413 | */ 414 | protected function prepareDataForInsert(Haystack $haystack): array 415 | { 416 | return $this->initialData->map(function (PendingData $pendingData) use ($haystack) { 417 | // We'll create a dummy Haystack data model for each row 418 | // and convert it into its attributes just for the casting. 419 | 420 | return $haystack->data()->make([ 421 | 'key' => $pendingData->key, 422 | 'cast' => $pendingData->cast, 423 | 'value' => $pendingData->value, 424 | ])->getAttributes(); 425 | })->toArray(); 426 | } 427 | 428 | /** 429 | * Create the haystack. 430 | */ 431 | protected function createHaystack(): Haystack 432 | { 433 | $haystack = new Haystack; 434 | $haystack->name = $this->name; 435 | $haystack->callbacks = $this->callbacks->toSerializable(); 436 | $haystack->middleware = $this->middleware->toSerializable(); 437 | $haystack->options = $this->options; 438 | $haystack->resume_at = $this->resumeAt; 439 | 440 | if ($this->beforeSave instanceof Closure) { 441 | $haystack = tap($haystack, $this->beforeSave); 442 | } 443 | 444 | $haystack->save(); 445 | 446 | // We'll bulk insert the jobs and the data for efficient querying. 447 | 448 | if ($this->jobs->isNotEmpty()) { 449 | $haystack->bales()->insert($this->prepareJobsForInsert($haystack)); 450 | } 451 | 452 | if ($this->initialData->isNotEmpty()) { 453 | $haystack->data()->insert($this->prepareDataForInsert($haystack)); 454 | } 455 | 456 | return $haystack; 457 | } 458 | 459 | /** 460 | * Specify if you do not want haystack to return the data. 461 | * 462 | * @return $this 463 | */ 464 | public function dontReturnData(): static 465 | { 466 | $this->options->returnDataOnFinish = false; 467 | 468 | return $this; 469 | } 470 | 471 | /** 472 | * Allow failures on the Haystack 473 | * 474 | * @return $this 475 | */ 476 | public function allowFailures(): static 477 | { 478 | $this->options->allowFailures = true; 479 | 480 | return $this; 481 | } 482 | 483 | /** 484 | * Get all the jobs in the builder. 485 | */ 486 | public function getJobs(): Collection 487 | { 488 | return $this->jobs; 489 | } 490 | 491 | /** 492 | * Retrieve the callbacks 493 | */ 494 | public function getCallbacks(): CallbackCollection 495 | { 496 | return $this->callbacks; 497 | } 498 | 499 | /** 500 | * Get the time for the "withDelay". 501 | */ 502 | public function getGlobalDelayInSeconds(): int 503 | { 504 | return $this->globalDelayInSeconds; 505 | } 506 | 507 | /** 508 | * Get the global queue 509 | */ 510 | public function getGlobalQueue(): ?string 511 | { 512 | return $this->globalQueue; 513 | } 514 | 515 | /** 516 | * Get the global connection. 517 | */ 518 | public function getGlobalConnection(): ?string 519 | { 520 | return $this->globalConnection; 521 | } 522 | 523 | /** 524 | * Get the closure for the global middleware. 525 | */ 526 | public function getMiddleware(): MiddlewareCollection 527 | { 528 | return $this->middleware; 529 | } 530 | 531 | /** 532 | * Specify a closure to run before saving the Haystack 533 | * 534 | * @return $this 535 | */ 536 | public function beforeSave(Closure $closure): static 537 | { 538 | $this->beforeSave = $closure; 539 | 540 | return $this; 541 | } 542 | 543 | /** 544 | * Set an option on the Haystack Options. 545 | * 546 | * @return $this 547 | */ 548 | public function setOption(string $option, mixed $value): static 549 | { 550 | $this->options->$option = $value; 551 | 552 | return $this; 553 | } 554 | } 555 | -------------------------------------------------------------------------------- /src/Casts/CallbackCollectionCast.php: -------------------------------------------------------------------------------- 1 | true]) : null; 22 | } 23 | 24 | /** 25 | * Serialize a job. 26 | * 27 | * @return mixed|string|null 28 | */ 29 | public function set($model, string $key, $value, array $attributes) 30 | { 31 | if (blank($value)) { 32 | return null; 33 | } 34 | 35 | if (! $value instanceof CallbackCollection) { 36 | throw new InvalidArgumentException(sprintf('Value provided must be an instance of %s.', CallbackCollection::class)); 37 | } 38 | 39 | return SerializationHelper::serialize($value); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Casts/MiddlewareCollectionCast.php: -------------------------------------------------------------------------------- 1 | true]) : null; 22 | } 23 | 24 | /** 25 | * Serialize a job. 26 | * 27 | * @return mixed|string|null 28 | */ 29 | public function set($model, string $key, $value, array $attributes) 30 | { 31 | if (blank($value)) { 32 | return null; 33 | } 34 | 35 | if (! $value instanceof MiddlewareCollection) { 36 | throw new InvalidArgumentException(sprintf('Value provided must be an instance of %s.', MiddlewareCollection::class)); 37 | } 38 | 39 | return SerializationHelper::serialize($value); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Casts/SerializeClosure.php: -------------------------------------------------------------------------------- 1 | true])->getClosure() : null; 24 | } 25 | 26 | /** 27 | * Serialize a closure. 28 | * 29 | * @return mixed|string|null 30 | * 31 | * @throws \Laravel\SerializableClosure\Exceptions\PhpVersionNotSupportedException 32 | */ 33 | public function set($model, string $key, $value, array $attributes): ?string 34 | { 35 | if (blank($value)) { 36 | return null; 37 | } 38 | 39 | if ($value instanceof Closure === false && is_callable($value) === false) { 40 | throw new InvalidArgumentException('Value provided must be a closure or an invokable class.'); 41 | } 42 | 43 | $closure = ClosureHelper::fromCallable($value); 44 | 45 | return SerializationHelper::serialize(new SerializableClosure($closure)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Casts/Serialized.php: -------------------------------------------------------------------------------- 1 | true]) : null; 20 | } 21 | 22 | /** 23 | * Serialize a job. 24 | * 25 | * @return mixed|string|null 26 | */ 27 | public function set($model, string $key, $value, array $attributes) 28 | { 29 | if (blank($value)) { 30 | return null; 31 | } 32 | 33 | return SerializationHelper::serialize($value); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Casts/SerializedModel.php: -------------------------------------------------------------------------------- 1 | true])->model : null; 23 | } 24 | 25 | /** 26 | * Serialize a model. 27 | * 28 | * @return mixed|string|null 29 | */ 30 | public function set($model, string $key, $value, array $attributes) 31 | { 32 | if (blank($value)) { 33 | return null; 34 | } 35 | 36 | if (! $value instanceof Model) { 37 | throw new InvalidArgumentException('The provided value must be a model.'); 38 | } 39 | 40 | return SerializationHelper::serialize(new SerializedModelData($value)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ChunkableHaystackJob.php: -------------------------------------------------------------------------------- 1 | haystack)) { 30 | parent::dispatchNextChunk($job); 31 | 32 | return; 33 | } 34 | 35 | $this->prependToHaystack($job, $this->chunkInterval); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Concerns/ManagesBales.php: -------------------------------------------------------------------------------- 1 | bales()->first(); 39 | } 40 | 41 | /** 42 | * Get the next job from the Haystack. 43 | */ 44 | public function getNextJob(): ?NextJob 45 | { 46 | $jobRow = $this->getNextJobRow(); 47 | 48 | if (! $jobRow instanceof HaystackBale) { 49 | return null; 50 | } 51 | 52 | // We'll retrieve the configured job which will have 53 | // the delay, queue and connection all set up. 54 | 55 | $job = $jobRow->configuredJob(); 56 | 57 | // We'll now set the Haystack model on the job. 58 | 59 | $job->setHaystack($this) 60 | ->setHaystackBaleId($jobRow->getKey()) 61 | ->setHaystackBaleAttempts($jobRow->attempts) 62 | ->setHaystackBaleRetryUntil($jobRow->retry_until); 63 | 64 | // We'll now apply any global middleware if it was provided to us 65 | // while building the Haystack. 66 | 67 | if ($this->middleware instanceof MiddlewareCollection) { 68 | $job->middleware = array_merge($job->middleware, $this->middleware->toMiddlewareArray()); 69 | } 70 | 71 | // Apply default middleware. We'll need to make sure that 72 | // the job middleware is added to the top of the array. 73 | 74 | $defaultMiddleware = [ 75 | new CheckFinished, 76 | new CheckAttempts, 77 | new IncrementAttempts, 78 | ]; 79 | 80 | $job->middleware = array_merge($defaultMiddleware, $job->middleware); 81 | 82 | // Return the NextJob DTO which contains the job and the row! 83 | 84 | return new NextJob($job, $jobRow); 85 | } 86 | 87 | /** 88 | * Dispatch the next job. 89 | * 90 | * 91 | * @throws PhpVersionNotSupportedException 92 | */ 93 | public function dispatchNextJob(?StackableJob $currentJob = null, int|CarbonInterface|null $delayInSecondsOrCarbon = null): void 94 | { 95 | // If the resume_at has been set, and the date is in the future, we're not allowed to process 96 | // the next job, so we stop. 97 | 98 | if ($this->resume_at instanceof CarbonInterface && $this->resume_at->isFuture()) { 99 | return; 100 | } 101 | 102 | if (is_null($currentJob) && $this->started === false) { 103 | $this->start(); 104 | 105 | return; 106 | } 107 | 108 | // If the job has been provided, we will delete the haystack bale to prevent 109 | // the same bale being retrieved on the next job. 110 | 111 | if (isset($currentJob)) { 112 | HaystackBale::query()->whereKey($currentJob->getHaystackBaleId())->delete(); 113 | } 114 | 115 | // If the delay in seconds has been provided, we need to pause the haystack by the 116 | // delay. 117 | 118 | if (isset($delayInSecondsOrCarbon)) { 119 | $this->pause(CarbonHelper::createFromSecondsOrCarbon($delayInSecondsOrCarbon)); 120 | 121 | return; 122 | } 123 | 124 | // Now we'll query the next bale. 125 | 126 | $nextJob = $this->getNextJob(); 127 | 128 | // If no next job was found, we'll stop. 129 | 130 | if (! $nextJob instanceof NextJob) { 131 | $this->finish(); 132 | 133 | return; 134 | } 135 | 136 | dispatch($nextJob->job); 137 | } 138 | 139 | /** 140 | * Start the Haystack. 141 | * 142 | * 143 | * @throws PhpVersionNotSupportedException 144 | */ 145 | public function start(): void 146 | { 147 | $this->update(['started_at' => now()]); 148 | 149 | $this->dispatchNextJob(); 150 | } 151 | 152 | /** 153 | * Restart the haystack 154 | * 155 | * 156 | * @throws PhpVersionNotSupportedException 157 | */ 158 | public function restart(): void 159 | { 160 | $this->dispatchNextJob(); 161 | } 162 | 163 | /** 164 | * Cancel the haystack. 165 | * 166 | * 167 | * @throws PhpVersionNotSupportedException 168 | */ 169 | public function cancel(): void 170 | { 171 | $this->finish(FinishStatus::Cancelled); 172 | } 173 | 174 | /** 175 | * Finish the Haystack. 176 | * 177 | * 178 | * @throws PhpVersionNotSupportedException 179 | */ 180 | public function finish(FinishStatus $status = FinishStatus::Success): void 181 | { 182 | if ($this->finished === true) { 183 | return; 184 | } 185 | 186 | $this->update(['finished_at' => now()]); 187 | 188 | $callbacks = $this->getCallbacks(); 189 | 190 | $data = $callbacks->isNotEmpty() ? $this->conditionallyGetAllData() : null; 191 | 192 | match ($status) { 193 | FinishStatus::Success => $this->invokeCallbacks($callbacks->onThen, $data), 194 | FinishStatus::Failure => $this->invokeCallbacks($callbacks->onCatch, $data), 195 | default => null, 196 | }; 197 | 198 | // Always execute the finally closure. 199 | 200 | $this->invokeCallbacks($callbacks->onFinally, $data); 201 | 202 | // Now finally delete itself. 203 | 204 | if ($status === FinishStatus::Success && config('haystack.delete_finished_haystacks') === true) { 205 | $this->delete(); 206 | } 207 | } 208 | 209 | /** 210 | * Fail the Haystack. 211 | * 212 | * 213 | * @throws PhpVersionNotSupportedException 214 | */ 215 | public function fail(): void 216 | { 217 | $this->finish(FinishStatus::Failure); 218 | } 219 | 220 | /** 221 | * Add new jobs to the haystack. 222 | */ 223 | public function addJobs(StackableJob|Collection|array $jobs, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null, bool $prepend = false): void 224 | { 225 | if ($jobs instanceof StackableJob) { 226 | $jobs = [$jobs]; 227 | } 228 | 229 | if ($jobs instanceof Collection) { 230 | $jobs = $jobs->all(); 231 | } 232 | 233 | $pendingJobs = []; 234 | 235 | foreach ($jobs as $job) { 236 | $pendingJobs[] = new PendingHaystackBale($job, $delayInSeconds, $queue, $connection, $prepend); 237 | } 238 | 239 | $this->addPendingJobs($pendingJobs); 240 | } 241 | 242 | /** 243 | * Add pending jobs to the haystack. 244 | */ 245 | public function addPendingJobs(array $pendingJobs): void 246 | { 247 | $pendingJobRows = collect($pendingJobs) 248 | ->filter(fn ($pendingJob) => $pendingJob instanceof PendingHaystackBale) 249 | ->map(fn (PendingHaystackBale $pendingJob) => $pendingJob->toDatabaseRow($this)) 250 | ->all(); 251 | 252 | if (empty($pendingJobRows)) { 253 | return; 254 | } 255 | 256 | $this->bales()->insert($pendingJobRows); 257 | } 258 | 259 | /** 260 | * Execute the closures. 261 | * 262 | * @param array $closures 263 | * 264 | * @throws PhpVersionNotSupportedException 265 | */ 266 | protected function invokeCallbacks(?array $closures, ?Collection $data = null): void 267 | { 268 | collect($closures)->each(function (SerializableClosure $closure) use ($data) { 269 | $closure($data); 270 | }); 271 | } 272 | 273 | /** 274 | * Pause the haystack. 275 | * 276 | * 277 | * @throws PhpVersionNotSupportedException 278 | */ 279 | public function pause(CarbonImmutable $resumeAt): void 280 | { 281 | $this->update(['resume_at' => $resumeAt]); 282 | 283 | $callbacks = $this->getCallbacks(); 284 | 285 | if (empty($callbacks->onPaused)) { 286 | return; 287 | } 288 | 289 | $data = $this->conditionallyGetAllData(); 290 | 291 | $this->invokeCallbacks($callbacks->onPaused, $data); 292 | } 293 | 294 | /** 295 | * Store data on the Haystack. 296 | * 297 | * @return ManagesBales|\Sammyjo20\LaravelHaystack\Models\Haystack 298 | */ 299 | public function setData(string $key, mixed $value, ?string $cast = null): self 300 | { 301 | DataValidator::validateCast($value, $cast); 302 | 303 | $this->data()->updateOrCreate(['key' => $key], [ 304 | 'cast' => $cast, 305 | 'value' => $value, 306 | ]); 307 | 308 | return $this; 309 | } 310 | 311 | /** 312 | * Retrieve data by a key from the Haystack. 313 | */ 314 | public function getData(string $key, mixed $default = null): mixed 315 | { 316 | $data = $this->data()->where('key', $key)->first(); 317 | 318 | return $data instanceof HaystackData ? $data->value : $default; 319 | } 320 | 321 | /** 322 | * Retrieve a shared model 323 | */ 324 | public function getModel(string $key, mixed $default = null): mixed 325 | { 326 | return $this->getData('model:'.$key, $default); 327 | } 328 | 329 | /** 330 | * Set a model on a Haystack 331 | * 332 | * @return $this 333 | * 334 | * @throws HaystackModelExists 335 | */ 336 | public function setModel(Model $model, ?string $key = null): static 337 | { 338 | $key = DataHelper::getModelKey($model, $key); 339 | 340 | if ($this->data()->where('key', $key)->exists()) { 341 | throw new HaystackModelExists($key); 342 | } 343 | 344 | return $this->setData($key, $model, SerializedModel::class); 345 | } 346 | 347 | /** 348 | * Retrieve all the data from the Haystack. 349 | */ 350 | public function allData(bool $includeModels = false): Collection 351 | { 352 | $data = $this->data() 353 | ->when($includeModels === false, fn ($query) => $query->where('key', 'NOT LIKE', 'model:%')) 354 | ->orderBy('id')->get(); 355 | 356 | return $data->mapWithKeys(function ($value, $key) { 357 | return [$value->key => $value->value]; 358 | }); 359 | } 360 | 361 | /** 362 | * Conditionally retrieve all the data from the Haystack depending on 363 | * if we are able to return the data. 364 | */ 365 | protected function conditionallyGetAllData(): ?Collection 366 | { 367 | $returnAllData = config('haystack.return_all_haystack_data_when_finished', false); 368 | 369 | return $this->options->returnDataOnFinish === true && $returnAllData === true ? $this->allData() : null; 370 | } 371 | 372 | /** 373 | * Increment the bale attempts. 374 | */ 375 | public function incrementBaleAttempts(StackableJob $job): void 376 | { 377 | HaystackBale::query()->whereKey($job->getHaystackBaleId())->increment('attempts'); 378 | } 379 | 380 | /** 381 | * Set the retry-until on the job. 382 | */ 383 | public function setBaleRetryUntil(StackableJob $job, int $retryUntil): void 384 | { 385 | HaystackBale::query()->whereKey($job->getHaystackBaleId())->update(['retry_until' => $retryUntil]); 386 | } 387 | 388 | /** 389 | * Get the callbacks on the Haystack. 390 | */ 391 | public function getCallbacks(): CallbackCollection 392 | { 393 | return $this->callbacks ?? new CallbackCollection; 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/Concerns/Stackable.php: -------------------------------------------------------------------------------- 1 | haystack; 46 | } 47 | 48 | /** 49 | * Set the Haystack onto the job. 50 | * 51 | * @return $this 52 | */ 53 | public function setHaystack(Haystack $haystack): static 54 | { 55 | $this->haystack = $haystack; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Dispatch the next job in the Haystack. 62 | * 63 | * @return $this 64 | * 65 | * @throws StackableException 66 | */ 67 | public function nextJob(int|CarbonInterface|null $delayInSecondsOrCarbon = null): static 68 | { 69 | if (config('haystack.process_automatically', false) === true) { 70 | throw new StackableException('The "nextJob" method is unavailable when "haystack.process_automatically" is enabled.'); 71 | } 72 | 73 | $this->haystack->dispatchNextJob($this, $delayInSecondsOrCarbon); 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * Dispatch the next bale in the haystack. Yee-haw! 80 | * 81 | * @return $this 82 | * 83 | * @throws StackableException 84 | */ 85 | public function nextBale(int|CarbonInterface|null $delayInSecondsOrCarbon = null): static 86 | { 87 | return $this->nextJob($delayInSecondsOrCarbon); 88 | } 89 | 90 | /** 91 | * Release the job for haystack to process later. 92 | * 93 | * @return $this 94 | */ 95 | public function longRelease(int|CarbonInterface $delayInSecondsOrCarbon): static 96 | { 97 | $resumeAt = CarbonHelper::createFromSecondsOrCarbon($delayInSecondsOrCarbon); 98 | 99 | $this->haystack->pause($resumeAt); 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Finish the Haystack. 106 | * 107 | * @return $this 108 | */ 109 | public function finishHaystack(): static 110 | { 111 | $this->haystack->finish(); 112 | 113 | return $this; 114 | } 115 | 116 | /** 117 | * Fail the job stack. 118 | * 119 | * @return $this 120 | */ 121 | public function failHaystack(): static 122 | { 123 | $this->haystack->finish(FinishStatus::Failure); 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Append jobs to the haystack. 130 | * 131 | * @return $this 132 | */ 133 | public function appendToHaystack(StackableJob|Collection|array $jobs, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 134 | { 135 | $this->haystack->addJobs($jobs, $delayInSeconds, $queue, $connection, false); 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * Prepend jobs to the haystack. 142 | * 143 | * @return $this 144 | */ 145 | public function prependToHaystack(StackableJob|Collection|array $jobs, int $delayInSeconds = 0, ?string $queue = null, ?string $connection = null): static 146 | { 147 | $this->haystack->addJobs($jobs, $delayInSeconds, $queue, $connection, true); 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * Get the haystack bale id 154 | */ 155 | public function getHaystackBaleId(): int 156 | { 157 | return $this->haystackBaleId; 158 | } 159 | 160 | /** 161 | * Set the Haystack bale ID. 162 | * 163 | * @return $this 164 | */ 165 | public function setHaystackBaleId(int $haystackBaleId): static 166 | { 167 | $this->haystackBaleId = $haystackBaleId; 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Pause the haystack. We also need to delete the current row. 174 | * 175 | * @return $this 176 | * 177 | * @throws StackableException 178 | */ 179 | public function pauseHaystack(int|CarbonInterface $delayInSecondsOrCarbon): static 180 | { 181 | if (config('haystack.process_automatically', false) === false) { 182 | throw new StackableException('The "pauseHaystack" method is unavailable when "haystack.process_automatically" is disabled. Use the "nextJob" with a delay provided instead.'); 183 | } 184 | 185 | $resumeAt = CarbonHelper::createFromSecondsOrCarbon($delayInSecondsOrCarbon); 186 | 187 | $this->haystack->pause($resumeAt); 188 | 189 | // We need to make sure that we delete the current haystack bale to stop it 190 | // from being processed when the haystack is resumed. 191 | 192 | HaystackBale::query()->whereKey($this->getHaystackBaleId())->delete(); 193 | 194 | return $this; 195 | } 196 | 197 | /** 198 | * Set data on the haystack. 199 | * 200 | * @return $this 201 | */ 202 | public function setHaystackData(string $key, mixed $value, ?string $cast = null): static 203 | { 204 | $this->haystack->setData($key, $value, $cast); 205 | 206 | return $this; 207 | } 208 | 209 | /** 210 | * Get data on the haystack. 211 | */ 212 | public function getHaystackData(string $key, mixed $default = null): mixed 213 | { 214 | return $this->haystack->getData($key, $default); 215 | } 216 | 217 | /** 218 | * Get a shared model 219 | */ 220 | public function getHaystackModel(string $model, mixed $default = null): ?Model 221 | { 222 | return $this->haystack->getModel($model, $default); 223 | } 224 | 225 | /** 226 | * Set a shared model 227 | * 228 | * @return $this 229 | * 230 | * @throws \Sammyjo20\LaravelHaystack\Exceptions\HaystackModelExists 231 | */ 232 | public function setHaystackModel(Model $model, string $key): static 233 | { 234 | $this->haystack->setModel($model, $key); 235 | 236 | return $this; 237 | } 238 | 239 | /** 240 | * Get all data on the haystack. 241 | * 242 | * @return mixed 243 | */ 244 | public function allHaystackData(): Collection 245 | { 246 | return $this->haystack->allData(); 247 | } 248 | 249 | /** 250 | * Get the haystack bale attempts. 251 | */ 252 | public function getHaystackBaleAttempts(): int 253 | { 254 | return $this->haystackBaleAttempts; 255 | } 256 | 257 | /** 258 | * Set the haystack bale attempts. 259 | * 260 | * @return $this 261 | */ 262 | public function setHaystackBaleAttempts(int $attempts): static 263 | { 264 | $this->haystackBaleAttempts = $attempts; 265 | 266 | return $this; 267 | } 268 | 269 | /** 270 | * Get the haystack bale retry-until. 271 | */ 272 | public function getHaystackBaleRetryUntil(): ?int 273 | { 274 | return $this->haystackBaleRetryUntil; 275 | } 276 | 277 | /** 278 | * Set the haystack bale retry-until. 279 | * 280 | * @return $this 281 | */ 282 | public function setHaystackBaleRetryUntil(?int $retryUntil): static 283 | { 284 | $this->haystackBaleRetryUntil = $retryUntil; 285 | 286 | return $this; 287 | } 288 | 289 | /** 290 | * Get the options on the Haystack 291 | */ 292 | public function getHaystackOptions(): HaystackOptions 293 | { 294 | return $this->haystack->options; 295 | } 296 | 297 | /** 298 | * Retrieve a haystack option 299 | */ 300 | public function getHaystackOption(string $option, mixed $default = null): mixed 301 | { 302 | return $this->haystack->options->$option ?? $default; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/Console/Commands/HaystackInstall.php: -------------------------------------------------------------------------------- 1 | info(' 🚀 | Installing Haystack'); 27 | 28 | $this->info(' 🪐 | Publishing migrations...'); 29 | 30 | $this->callSilently('vendor:publish', ['--tag' => 'haystack-migrations']); 31 | 32 | $this->info(' 🔭 | Publishing config...'); 33 | 34 | $this->callSilently('vendor:publish', ['--tag' => 'haystack-config']); 35 | 36 | $runMigrations = $this->confirm('Would you like to run migrations?', false); 37 | 38 | if ($runMigrations) { 39 | $this->callSilently('migrate'); 40 | 41 | $this->info(' 🎯 | Migrations run successfully'); 42 | } 43 | 44 | if ($this->confirm(' 🤩 | Would you like to star the repo on GitHub?')) { 45 | $repoUrl = 'https://github.com/sammyjo20/laravel-haystack'; 46 | 47 | if (PHP_OS_FAMILY == 'Darwin') { 48 | exec("open {$repoUrl}"); 49 | } 50 | 51 | if (PHP_OS_FAMILY == 'Windows') { 52 | exec("start {$repoUrl}"); 53 | } 54 | 55 | if (PHP_OS_FAMILY == 'Linux') { 56 | exec("xdg-open {$repoUrl}"); 57 | } 58 | } 59 | 60 | $this->info(' ✅ | Haystack has been installed. Thank you for using Haystack. Happy developing! ❤️'); 61 | 62 | return self::SUCCESS; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Console/Commands/HaystacksClear.php: -------------------------------------------------------------------------------- 1 | delete(); 19 | 20 | $this->info("Cleared $count haystacks"); 21 | 22 | return self::SUCCESS; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Console/Commands/HaystacksForget.php: -------------------------------------------------------------------------------- 1 | argument('id')); 19 | 20 | if (! $haystack) { 21 | $this->error('No haystack matches the given ID.'); 22 | 23 | return self::FAILURE; 24 | } 25 | 26 | $haystack->delete(); 27 | $this->info('Haystack deleted successfully!'); 28 | 29 | return self::SUCCESS; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Console/Commands/HaystacksResume.php: -------------------------------------------------------------------------------- 1 | where('resume_at', '<=', now())->cursor(); 19 | 20 | foreach ($haystacks as $haystack) { 21 | $haystack->update(['resume_at' => null]); 22 | $haystack->dispatchNextJob(); 23 | } 24 | 25 | return self::SUCCESS; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contracts/StackableJob.php: -------------------------------------------------------------------------------- 1 | addCallback('onThen', $closure); 43 | } 44 | 45 | /** 46 | * Add a "catch" callback 47 | * 48 | * @return $this 49 | * 50 | * @throws \Laravel\SerializableClosure\Exceptions\PhpVersionNotSupportedException 51 | */ 52 | public function addCatch(Closure|callable $closure): static 53 | { 54 | return $this->addCallback('onCatch', $closure); 55 | } 56 | 57 | /** 58 | * Add a "finally" callback 59 | * 60 | * @return $this 61 | * 62 | * @throws \Laravel\SerializableClosure\Exceptions\PhpVersionNotSupportedException 63 | */ 64 | public function addFinally(Closure|callable $closure): static 65 | { 66 | return $this->addCallback('onFinally', $closure); 67 | } 68 | 69 | /** 70 | * Add a "paused" callback 71 | * 72 | * @return $this 73 | * 74 | * @throws \Laravel\SerializableClosure\Exceptions\PhpVersionNotSupportedException 75 | */ 76 | public function addPaused(Closure|callable $closure): static 77 | { 78 | return $this->addCallback('onPaused', $closure); 79 | } 80 | 81 | /** 82 | * Add a callback to a given property 83 | * 84 | * @return $this 85 | * 86 | * @throws \Laravel\SerializableClosure\Exceptions\PhpVersionNotSupportedException 87 | */ 88 | protected function addCallback(string $property, Closure|callable $closure): static 89 | { 90 | $this->$property[] = new SerializableClosure(ClosureHelper::fromCallable($closure)); 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Check if the callbacks are empty. 97 | */ 98 | public function isEmpty(): bool 99 | { 100 | return empty($this->onThen) && empty($this->onCatch) && empty($this->onFinally) && empty($this->onPaused); 101 | } 102 | 103 | /** 104 | * Check if the callbacks are not empty 105 | */ 106 | public function isNotEmpty(): bool 107 | { 108 | return ! $this->isEmpty(); 109 | } 110 | 111 | /** 112 | * Convert the object ready to be serialized 113 | * 114 | * @return $this|null 115 | */ 116 | public function toSerializable(): ?static 117 | { 118 | return $this->isNotEmpty() ? $this : null; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Data/HaystackOptions.php: -------------------------------------------------------------------------------- 1 | $name = $value; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Data/MiddlewareCollection.php: -------------------------------------------------------------------------------- 1 | $value; 30 | } 31 | 32 | $this->data[] = new SerializableClosure(ClosureHelper::fromCallable($value)); 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * Call the whole middleware stack and convert it into an array 39 | */ 40 | public function toMiddlewareArray(): array 41 | { 42 | return collect($this->data) 43 | ->map(function (SerializableClosure $closure) { 44 | $result = $closure(); 45 | 46 | return is_array($result) ? $result : [$result]; 47 | }) 48 | ->flatten() 49 | ->filter(fn ($value) => is_object($value)) 50 | ->toArray(); 51 | } 52 | 53 | /** 54 | * Check if the middleware is empty 55 | */ 56 | public function isEmpty(): bool 57 | { 58 | return empty($this->data); 59 | } 60 | 61 | /** 62 | * Check if the middleware is not empty 63 | */ 64 | public function isNotEmpty(): bool 65 | { 66 | return ! $this->isEmpty(); 67 | } 68 | 69 | /** 70 | * Convert the object ready to be serialized 71 | * 72 | * @return $this|null 73 | */ 74 | public function toSerializable(): ?static 75 | { 76 | return $this->isNotEmpty() ? $this : null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Data/NextJob.php: -------------------------------------------------------------------------------- 1 | job->delay; 24 | $nativeQueue = $this->job->queue; 25 | $nativeConnection = $this->job->connection; 26 | 27 | if (isset($nativeDelay) && $this->delayInSeconds <= 0) { 28 | $this->delayInSeconds = $nativeDelay; 29 | } 30 | 31 | if (isset($nativeQueue) && ! isset($this->queue)) { 32 | $this->queue = $nativeQueue; 33 | } 34 | 35 | if (isset($nativeConnection) && ! isset($this->connection)) { 36 | $this->connection = $nativeConnection; 37 | } 38 | } 39 | 40 | /** 41 | * Convert to a haystack bale for casting. 42 | */ 43 | public function toDatabaseRow(Haystack $haystack): array 44 | { 45 | $now = Carbon::now(); 46 | 47 | return $haystack->bales()->make([ 48 | 'created_at' => $now, 49 | 'updated_at' => $now, 50 | 'job' => $this->job, 51 | 'delay' => $this->delayInSeconds, 52 | 'on_queue' => $this->queue, 53 | 'on_connection' => $this->connection, 54 | 'priority' => $this->priority, 55 | ])->getAttributes(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Data/SerializedModel.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 26 | __DIR__.'/../config/haystack.php', 27 | 'haystack' 28 | ); 29 | } 30 | 31 | /** 32 | * Bootstrap any package services. 33 | */ 34 | public function boot(): void 35 | { 36 | $this->publishConfigAndMigrations() 37 | ->registerCommands() 38 | ->registerQueueListeners(); 39 | } 40 | 41 | /** 42 | * Public the config file and migrations 43 | * 44 | * @return $this 45 | */ 46 | protected function publishConfigAndMigrations(): static 47 | { 48 | $this->publishes([ 49 | __DIR__.'/../config/haystack.php' => config_path('haystack.php'), 50 | ], 'haystack-config'); 51 | 52 | $this->publishes([ 53 | __DIR__.'/../database/migrations/create_haystacks_table.php.stub' => database_path('migrations/'.now()->format('Y_m_d_His').'_create_haystacks_table.php'), 54 | __DIR__.'/../database/migrations/create_haystack_bales_table.php.stub' => database_path('migrations/'.now()->addSeconds(1)->format('Y_m_d_His').'_create_haystack_bales_table.php'), 55 | __DIR__.'/../database/migrations/create_haystack_data_table.php.stub' => database_path('migrations/'.now()->addSeconds(2)->format('Y_m_d_His').'_create_haystack_data_table.php'), 56 | ], 'haystack-migrations'); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * Register commands 63 | * 64 | * @return $this 65 | */ 66 | protected function registerCommands(): static 67 | { 68 | if (! $this->app->runningInConsole()) { 69 | return $this; 70 | } 71 | 72 | $this->commands([ 73 | HaystacksClear::class, 74 | HaystacksForget::class, 75 | HaystacksResume::class, 76 | HaystackInstall::class, 77 | ]); 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Listen to the queue events. 84 | * 85 | * @return $this 86 | */ 87 | public function registerQueueListeners(): static 88 | { 89 | if (config('haystack.process_automatically') !== true) { 90 | return $this; 91 | } 92 | 93 | Queue::createPayloadUsing(fn ($connection, $queue, $payload) => JobEventListener::make()->createPayloadUsing($connection, $queue, $payload)); 94 | 95 | Queue::after(fn (JobProcessed $event) => JobEventListener::make()->handleJobProcessed($event)); 96 | 97 | Queue::failing(fn (JobFailed $event) => JobEventListener::make()->handleFailedJob($event)); 98 | 99 | return $this; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Helpers/CarbonHelper.php: -------------------------------------------------------------------------------- 1 | addSeconds($value) : $value->toImmutable(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Helpers/ClosureHelper.php: -------------------------------------------------------------------------------- 1 | $value(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Helpers/DataHelper.php: -------------------------------------------------------------------------------- 1 | $data]; 37 | } 38 | 39 | // If we are about to process a StackableJob, we should add the "haystack_id" 40 | // to the job payload. This will help us resolve the haystack when the job 41 | // is processed by the "handleJobProcessed" event. 42 | 43 | $data = array_merge($payload['data'], [ 44 | 'haystack_id' => $command->getHaystack()->getKey(), 45 | ]); 46 | 47 | return ['data' => $data]; 48 | } 49 | 50 | /** 51 | * Handle the "JobProcessed" event. 52 | */ 53 | public function handleJobProcessed(JobProcessed $event): void 54 | { 55 | $processedJob = $event->job; 56 | $payload = $processedJob->payload(); 57 | 58 | // We'll firstly attempt to get the haystack from the payload. The reason 59 | // we do this first, is because if we attempt to unserialize the job 60 | // first and the haystack is deleted, it will throw an exception 61 | // because the model now longer exists. 62 | 63 | $haystack = $this->getHaystackFromPayload($payload); 64 | 65 | if (! $haystack instanceof Haystack) { 66 | return; 67 | } 68 | 69 | // We will next attempt to decode the "command" from the job payload. 70 | // The command data contains a serialized version of the job. 71 | 72 | $job = $this->unserializeJobFromPayload($payload); 73 | 74 | if (! $job instanceof StackableJob) { 75 | return; 76 | } 77 | 78 | // Once we have unserialized the job, and we know it is a stackable job 79 | // we should check if the job has been released. If the job has been 80 | // released we will wait for the job to be processed again. If the 81 | // processed job is a "SyncJob" we should ignore the check since 82 | // sync jobs will be processed straight away. 83 | 84 | if ($processedJob instanceof SyncJob === false && $processedJob->isReleased() === true) { 85 | return; 86 | } 87 | 88 | // Once we have found the Haystack, we'll check if the current job has 89 | // failed. If it has, then we'll just stop here. If it has failed 90 | // the fail handler will continue for us. 91 | 92 | if ($processedJob->hasFailed()) { 93 | return; 94 | } 95 | 96 | // Dispatch the next job... 97 | 98 | $haystack->dispatchNextJob($job); 99 | } 100 | 101 | /** 102 | * Handle the "JobFailed" event. 103 | */ 104 | public function handleFailedJob(JobFailed $event): void 105 | { 106 | $processedJob = $event->job; 107 | $payload = $processedJob->payload(); 108 | 109 | // We'll firstly attempt to get the haystack from the payload. The reason 110 | // we do this first, is because if we attempt to unserialize the job 111 | // first and the haystack is deleted, it will throw an exception 112 | // because the model now longer exists. 113 | 114 | $haystack = $this->getHaystackFromPayload($payload); 115 | 116 | if (! $haystack instanceof Haystack) { 117 | return; 118 | } 119 | 120 | // We will next attempt to decode the "command" from the job payload. 121 | // The command data contains a serialized version of the job. 122 | 123 | $job = $this->unserializeJobFromPayload($payload); 124 | 125 | if (! $job instanceof StackableJob) { 126 | return; 127 | } 128 | 129 | // If allow failures is turned on, we'll dispatch the next job. 130 | 131 | if ($haystack->options->allowFailures === true) { 132 | $haystack->dispatchNextJob($job); 133 | 134 | return; 135 | } 136 | 137 | // Otherwise we'll fail the Haystack 138 | 139 | $haystack->fail(); 140 | } 141 | 142 | /** 143 | * Unserialize the job from the job payload. 144 | */ 145 | private function unserializeJobFromPayload(array $payload): ?object 146 | { 147 | if (! isset($payload['data']['command'])) { 148 | return null; 149 | } 150 | 151 | return SerializationHelper::unserialize($payload['data']['command'], ['allowed_classes' => true]); 152 | } 153 | 154 | /** 155 | * Attempt to find the haystack model from the job payload. 156 | */ 157 | private function getHaystackFromPayload(array $payload): ?Haystack 158 | { 159 | $haystackId = $payload['data']['haystack_id'] ?? null; 160 | 161 | if (blank($haystackId)) { 162 | return null; 163 | } 164 | 165 | return Haystack::find($haystackId); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Middleware/CheckAttempts.php: -------------------------------------------------------------------------------- 1 | getHaystackBaleRetryUntil())) { 25 | $exceededRetryUntil = now()->greaterThan(CarbonImmutable::parse($job->getHaystackBaleRetryUntil())); 26 | } else { 27 | $maxTries = $job->tries ?? 1; 28 | } 29 | 30 | $exceededLimit = (isset($maxTries) && $job->getHaystackBaleAttempts() >= $maxTries) || $exceededRetryUntil === true; 31 | 32 | if ($exceededLimit === true) { 33 | $exception = ExceptionHelper::maxAttemptsExceededException($job); 34 | 35 | $job->fail($exception); 36 | 37 | throw $exception; 38 | } 39 | 40 | $next($job); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Middleware/CheckFinished.php: -------------------------------------------------------------------------------- 1 | getHaystack()->finished === true) { 17 | return; 18 | } 19 | 20 | $next($job); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Middleware/IncrementAttempts.php: -------------------------------------------------------------------------------- 1 | getHaystack(); 17 | 18 | // First we'll increment the bale attempts. 19 | 20 | $haystack->incrementBaleAttempts($job); 21 | 22 | // When the "retryUntil" method is used we need to store the timestamp 23 | // that was generated by the queue worker. if the job is paused and 24 | // retried later we will resolve this timestamp and then check if 25 | // it has expired from the original queue push. 26 | 27 | if (method_exists($job, 'retryUntil') && is_null($job->getHaystackBaleRetryUntil())) { 28 | $retryUntil = $job->job?->retryUntil(); 29 | 30 | if (is_int($retryUntil)) { 31 | $haystack->setBaleRetryUntil($job, $retryUntil); 32 | } 33 | } 34 | 35 | $next($job); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Models/Haystack.php: -------------------------------------------------------------------------------- 1 | CallbackCollectionCast::class, 46 | 'middleware' => MiddlewareCollectionCast::class, 47 | 'options' => Serialized::class, 48 | 'started_at' => 'immutable_datetime', 49 | 'resume_at' => 'immutable_datetime', 50 | 'finished_at' => 'immutable_datetime', 51 | ]; 52 | 53 | /** 54 | * Create a new factory instance for the model. 55 | * 56 | * @return \Illuminate\Database\Eloquent\Factories\Factory 57 | */ 58 | protected static function newFactory() 59 | { 60 | return HaystackFactory::new(); 61 | } 62 | 63 | /** 64 | * Get the prunable model query. 65 | */ 66 | public function prunable(): Builder 67 | { 68 | $staleHaystackDays = config('haystack.keep_stale_haystacks_for_days', 0); 69 | $finishedHaystackDays = config('haystack.keep_finished_haystacks_for_days', 0); 70 | 71 | return static::query() 72 | ->where(function ($query) use ($staleHaystackDays) { 73 | $query->whereNull('finished_at')->where('started_at', '<=', now()->subDays($staleHaystackDays)); 74 | }) 75 | ->orWhere(function ($query) use ($finishedHaystackDays) { 76 | $query->whereNotNull('started_at')->where('finished_at', '<=', now()->subDays($finishedHaystackDays)); 77 | }); 78 | } 79 | 80 | /** 81 | * The Haystack's bales. 82 | */ 83 | public function bales(): HasMany 84 | { 85 | return $this->hasMany(HaystackBale::class, 'haystack_id', 'id')->orderBy('priority', 'desc')->orderBy('id', 'asc'); 86 | } 87 | 88 | /** 89 | * The Haystack's data. 90 | */ 91 | public function data(): HasMany 92 | { 93 | return $this->hasMany(HaystackData::class); 94 | } 95 | 96 | /** 97 | * Start building a Haystack. 98 | */ 99 | public static function build(): HaystackBuilder 100 | { 101 | return new HaystackBuilder; 102 | } 103 | 104 | /** 105 | * Denotes if the haystack has started. 106 | */ 107 | public function getStartedAttribute(): bool 108 | { 109 | return $this->started_at instanceof CarbonImmutable; 110 | } 111 | 112 | /** 113 | * Denotes if the haystack has finished. 114 | */ 115 | public function getFinishedAttribute(): bool 116 | { 117 | return $this->finished_at instanceof CarbonImmutable; 118 | } 119 | 120 | /** 121 | * Get the current connection name for the model. 122 | */ 123 | public function getConnectionName(): string 124 | { 125 | return config('haystack.db_connection'); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Models/HaystackBale.php: -------------------------------------------------------------------------------- 1 | Serialized::class, 28 | 'priority' => 'boolean', 29 | ]; 30 | 31 | /** 32 | * Create a new factory instance for the model. 33 | * 34 | * @return \Illuminate\Database\Eloquent\Factories\Factory 35 | */ 36 | protected static function newFactory() 37 | { 38 | return HaystackBaleFactory::new(); 39 | } 40 | 41 | /** 42 | * The Haystack this row belongs to. 43 | */ 44 | public function haystack(): BelongsTo 45 | { 46 | return $this->belongsTo(Haystack::class); 47 | } 48 | 49 | /** 50 | * Get the job already configured. 51 | */ 52 | public function configuredJob(): StackableJob 53 | { 54 | $job = $this->job; 55 | 56 | if ($this->delay > 0) { 57 | $job->delay($this->delay); 58 | } 59 | 60 | if (filled($this->on_queue)) { 61 | $job->onQueue($this->on_queue); 62 | } 63 | 64 | if (filled($this->on_connection)) { 65 | $job->onConnection($this->on_connection); 66 | } 67 | 68 | return $job; 69 | } 70 | 71 | /** 72 | * Get the current connection name for the model. 73 | */ 74 | public function getConnectionName(): string 75 | { 76 | return config('haystack.db_connection'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Models/HaystackData.php: -------------------------------------------------------------------------------- 1 | belongsTo(Haystack::class); 37 | } 38 | 39 | /** 40 | * Set the cast attribute and apply the casts. 41 | */ 42 | public function setCastAttribute(?string $cast): void 43 | { 44 | if (! is_null($cast)) { 45 | $this->casts = ['value' => $cast]; 46 | } 47 | 48 | $this->attributes['cast'] = $cast; 49 | $this->attributes['value'] = null; 50 | } 51 | 52 | /** 53 | * Get the cast value. 54 | */ 55 | public function getValueAttribute($value): mixed 56 | { 57 | if (blank($this->cast)) { 58 | return $value; 59 | } 60 | 61 | // We'll now manually add the cast and attempt to cast the attribute. 62 | 63 | $this->casts = ['value' => $this->cast]; 64 | 65 | return $this->castAttribute('value', $value); 66 | } 67 | 68 | /** 69 | * Get the current connection name for the model. 70 | */ 71 | public function getConnectionName(): string 72 | { 73 | return config('haystack.db_connection'); 74 | } 75 | } 76 | --------------------------------------------------------------------------------