Skip to content

Commit e23e526

Browse files
author
Nathan Heffley
committed
Initial Release
0 parents  commit e23e526

38 files changed

+1396
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor
2+
composer.lock
3+
.phpunit.result.cache

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2019 DealerInspire
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 all
13+
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 THE
21+
SOFTWARE.

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Laravel Operations
2+
3+
Operations allow you to schedule your jobs in a programmatic, database-driven way. By creating an operation record in your database and then calling the Operator to queue any operations that are ready to be run, you can schedule tasks for execution in the far future and keep track of every operation that has already been run.
4+
5+
## Installation
6+
7+
Install the package by requiring it with Composer.
8+
9+
```bash
10+
composer require dealerinspire/laravel-operations
11+
```
12+
13+
The service provider will get registered automatically, but you will need to publish the config file.
14+
15+
```bash
16+
php artisan vendor:publish --provider="DealerInspire\Operations\OperationServiceProvider"
17+
```
18+
19+
## Usage
20+
21+
Here is an example of a very simple Operation:
22+
23+
```php
24+
class ExampleOperation extends Operation
25+
{
26+
public function run()
27+
{
28+
// Logic goes in here to be executed when the operation is run.
29+
}
30+
}
31+
```
32+
33+
_If you would like to use dependency injection, you can add type-hinted parameters to your `run` function to be injected at runtime._
34+
35+
Operations are Eloquent models, and can have whatever columns on them that you want. The only requirement is that they have three additional timestamp columns, `should_run_at`, `started_run_at`, and `finished_run_at`.
36+
37+
To make sure that the Operator knows about all of your operations you should add the classname to the `config\operations.php` file.
38+
39+
```php
40+
'operations' => [
41+
\App\Operations\ExampleOperation::class,
42+
],
43+
```
44+
45+
To put any operations that are ready to run into your job queue, you should call the Operator's `queue` function.
46+
47+
```php
48+
// Using the facade.
49+
50+
use DealerInspire\Operations\Facades\Operator;
51+
52+
// ...
53+
54+
Operator::queue();
55+
```
56+
57+
```php
58+
// Using an Operator instance.
59+
60+
use DealerInspire\Operations\Operator;
61+
62+
// ...
63+
64+
$operator = new Operator();
65+
$operator->queue();
66+
```
67+
68+
Any operation that has a `should_run_at` timestamp which is in the past and has a null `started_run_at` value (and also hasn't been deleted) will be put into your job queue.
69+
70+
An operation's `started_run_at` timestamp will be set to the current time as soon as it is placed into your queue (not when the job actually beings getting processed by a worker). Once the operation has been run by a worker the `finished_run_at` timestamp will be set.
71+
72+
You can quickly create new operations (which will be placed in your `App\Operations` directory) by running the `make:operation`.
73+
74+
```bash
75+
php artisan make:operation MyNewOperation
76+
```
77+
78+
There is also a `--migration` flag that you can use to create a migration with the necessary timestamps.
79+
80+
## Documentation
81+
82+
More in-depth information on best practices and how to use operations effectively.
83+
84+
- [Creating Your First Operation](/docs/first-operation.md)
85+
- [How to Queue Operations](/docs/queueing.md)
86+
- [Handling Failures Gracefully](/docs/failing.md)
87+
88+
## License
89+
90+
MIT © [Dealer Inspire](https://www.dealerinspire.com/)

composer.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "dealerinspire/laravel-operations",
3+
"description": "Operations allow you to schedule your jobs in a programmatic, database-driven way",
4+
"version": "v0.1.0",
5+
"keywords": [
6+
"laravel-operations",
7+
"operations",
8+
"jobs",
9+
"laravel"
10+
],
11+
"license": "MIT",
12+
"require": {
13+
"php": "^7.1",
14+
"laravel/framework": "~5.8"
15+
},
16+
"require-dev": {
17+
"orchestra/testbench": "^3.8"
18+
},
19+
"autoload": {
20+
"psr-4": {
21+
"DealerInspire\\Operations\\": "src"
22+
}
23+
},
24+
"autoload-dev": {
25+
"psr-4": {
26+
"DealerInspire\\Operations\\Tests\\": "tests"
27+
}
28+
},
29+
"config": {
30+
"sort-packages": true
31+
},
32+
"extra": {
33+
"laravel": {
34+
"providers": [
35+
"DealerInspire\\Operations\\OperationServiceProvider"
36+
],
37+
"aliases": {
38+
"Operator": "DealerInspire\\Operations\\Facades\\Operations"
39+
}
40+
}
41+
}
42+
}

config/operations.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
return [
4+
/*
5+
* These Operations are the ones that will be checked
6+
* and queued when you run the Operator's queue function.
7+
*/
8+
'operations' => [
9+
// \App\Operations\YourOperation::class,
10+
],
11+
];

docs/failing.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Handling Failures Gracefully
2+
3+
Unfortunately, sometimes background jobs fail, and this includes operations. Even if your code is completely bug free, external services and databases can go down. Handling failures gracefully is important in any situation, but it's especially important in background jobs.
4+
5+
To help you fail more gracefully an operation has two functions: `stop()` and `cancel()`.
6+
7+
## `stop()`
8+
9+
Stopping an operation is for when something has happened which means you won't be able to finish the job, but you want to try again later. This is useful if you are calling an API and get some sort of `500` error where the only choice is to wait and try again later.
10+
11+
Whenever you call `stop()` on an operation it will throw an exception (thus halting the code in your operation's `run` function) which is then immediately caught by the job that is running your operation. The job will set your operation back to a state where it is ready to run so that it can get picked up by the Operator again. The job that is running your operation will finish successfully, which means that it won't retry the job multiple times.
12+
13+
## `cancel()`
14+
15+
Canceling an operation is for when you want to stop running your operation _and stop it from trying again._ This can be useful if the operation has failed catastrophically and will never be able to complete, but it can also be useful if your operation was scheduled to run but is no longer applicable.
16+
17+
Similar to `stop()`, `cancel()` will throw an exception that is immediately caught by the job running the operation. The only difference is that instead of setting the operation to a state where it get queued again by the Operator, the operation is deleted. The job running it finishes successfully, so that it won't retry multiple times.
18+
19+
## Other types of exceptions
20+
21+
Of course, you can simply throw whatever kind of exception you want within an operation and it won't be caught like the exceptions from `stop()` and `cancel()`. Other exceptions will go all the way up to your exception handler and can be handled or sent to a bug tracking tool like any other exception.
22+
23+
If an unhandled exception is encountered, the operation job will retry up to whatever number of tries your workers are using, just like any other job. If you keep track of which jobs have failed, once the maximum number of tries has been exceeded you will be able to see the failed `OperationJob` there along with your other jobs.
24+
25+
If the operation job was not able to successfully run the operation and an exception is still being thrown, your operation will become **stuck**. This simply means that the operation has a `started_run_at` timestamp set but it is not actually being run. Because the Operator will not start running an operation that has a `should_run_at` timestamp set, the operation will never finish running.
26+
27+
This is why it is important to properly `stop()` or `cancel()` an operation if you are aware of an error case that you want to handle automatically.

docs/first-operation.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Creating Your First Operation
2+
3+
After installing the package you'll no doubt want to get started by creating an operation.
4+
5+
In this guide, we'll imagine that we're building an app where every user has a `rank` column that just holds a string. When the user first signs up they have a "New" rank, and after one week they get upgraded to be a "Beginner". Our operation will be scheduled to run one week after the user signs up and will update that `rank` column to the "Beginner" value.
6+
7+
If you would like to follow along in your own project, you can create a fresh Laravel installation, set up the authentication scaffolding, and add a string `rank` column to your user model. Then follow the instructions in the [README](/README.md#installation) to install the package.
8+
9+
To begin, you'll need an operation model and a database table for it. You can create these easily using the `make:operation` command with a flag to also create the migration:
10+
11+
```bash
12+
php artisan make:operation RankUpUserOperation -m
13+
```
14+
15+
This will give you two new files: `app\Operations\RankUpUserOperation.php` and a `create_rank_up_user_operations` migration.
16+
17+
Because this operation is updating a column on a user model, we'll need to be able to store which user ID we need to update. Open up the migration file and add an additional `user_id` column:
18+
19+
```php
20+
<?php
21+
22+
// Use Statements
23+
24+
class CreateRankUpUserOperations extends Migration
25+
{
26+
public function up() {
27+
Schema::create('rank_up_user_operations', function (Blueprint $table) {
28+
$table->bigIncrements('id');
29+
30+
// Custom User ID Column
31+
$table->unsignedBigInteger('user_id');
32+
33+
// Timestamps
34+
});
35+
}
36+
37+
// ...
38+
}
39+
```
40+
41+
You can now run `php artisan migrate` to create the table. Once that is done we can move on to writing the code in the operation.
42+
43+
> **Remember!** Every operation needs to have a `run` method implemented. The abstract Operation object that your Operation extends doesn't have an abstract `run` method to allow for dependency injection, so it's up to you to remember. But don't worry too much; if you forget to add it you will be reminded by an exception as soon as you try to use it.
44+
45+
```php
46+
<?php
47+
48+
namespace App\Operations;
49+
50+
use App\User;
51+
use DealerInspire\Operations\Operation;
52+
53+
class RankUpUserOperation extends Operation
54+
{
55+
public function run()
56+
{
57+
User::where('id', $this->user_id)->update(['rank' => 'Beginner']);
58+
}
59+
}
60+
```
61+
62+
Simple as that! Now any time our operation is run, the user associated to this operation will get a rank of `Beginner`.
63+
64+
We've created our new `RankUpUserOperation` but before we can start using it we need to make sure that we register it in our `operations.php` config file. If you don't have that file yet, make sure to run the `vendor:publish` artisan command to get it. Once you have that file you just need to reference your new operation class in the `operations` array:
65+
66+
```php
67+
'operations' => [
68+
\App\Operations\RankUpUserOperation::class,
69+
],
70+
```
71+
72+
Now that we have an operation and have it registered so that the package knows about it, we need a way to save new operations to our database. In this case, we've decided that this operation should run for each user one week after they sign up.
73+
74+
To accomplish this we could use an observer and listen for any time a User is created, but for the purpose of keeping it simple we'll just create an operation at the same time we create the user. To do that you should open up `app\Http\Controllers\Auth\RegisterController.php` and change the `create` function to look like the following:
75+
76+
```php
77+
protected function create(array $data)
78+
{
79+
$user = User::create(/* ... */);
80+
81+
RankUpUserOperation::schedule(Carbon::now()->addWeek(), [
82+
'user_id' => $user->id,
83+
]);
84+
85+
return $user;
86+
}
87+
```
88+
89+
The static `schedule` function is a helpful little way to more coherently create an operation. It simply takes the time when you want the operation to run and an optional array if you have additional attributes you need to set, like our `user_id`. If you don't have any custom attributes to set then you can just leave off the array entirely. If you're not a fan of these kinds of static methods, don't worry. You can still create an instance of your operation and save it however you'd like, just so long as you remember to set `should_run_at`.
90+
91+
If you browse to your Laravel project now in your browser and register a new account, you should not only see a new record in the `users` table, but also a new record in the `rank_up_user_operations` table that has your new user's ID and a `should_run_at` timestamp set to one week in the future.
92+
93+
You could wait a week to try and see if the operation works, but since I'm rather impatient I'm going to modify my database record to have a `should_run_at` timestamp of a few minutes in the future. You could also remove the `addWeek` call on the Carbon instance and create another new user if you don't want to tinker around in your database.
94+
95+
In the next guide we'll be going over how you can actually get these operations to run when they're ready. For right now I'm going to use artisan tinker to do it, by calling the Operator's `queue` method through the facade.
96+
97+
```bash
98+
php artisan tinker
99+
```
100+
101+
```
102+
DealerInspire\Operations\Facades\Operator::queue()
103+
```
104+
105+
If your queue driver is set to `sync` then everything should finish, well, synchronously. If you have your driver set to anything else make sure to run `php artisan queue:work` or whatever else you need to do to handle the job that gets created by running the `queue` command.
106+
107+
After the job has finished processing you should see that the Operation record in the database has timestamps set for `started_run_at` and `finished_run_at`, and the User record's `rank` has been updated to "Beginner".
108+
109+
Next: [How to Queue Operations](/docs/queueing.md)

docs/queueing.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# How to Queue Operations
2+
3+
You can create all the operations you want and save dozens of them in your database. But unless you actually queue them when they're ready to run, not much good will come of it.
4+
5+
If you went through the guide to [create your first operation](/docs/first-operation.md) then you already caught a glimpse of the `Operator::queue()` facade method. This is at the core of queueing operations, so we'll go into it in a bit more depth.
6+
7+
The `Operator` facade just calls the `DealerInspire\Operations\Operator` class, which is where you'll find the `queue` method. If you're not a fan of facades then you can instantiate an `Operator` instance or use dependency injection to get an instance whenever you want to call `queue`. For the purposes of this document though we'll continue referencing the facade.
8+
9+
Whenever you call `Operator::queue()` the `Operator` looks through all of your operations to see which runs are ready to be run.
10+
11+
## How does it know what operations to look for?
12+
13+
This is why you need to register any operations that you want the operator to pick up in the `config/operations.php` file. The operator uses the `operations` array to find which tables are holding your operations.
14+
15+
## How does it know which operations are ready to be run?
16+
17+
This is where the three operation specific timestamps come into play. First, any operation which has a `started_run_at` timestamp set is not considered ready. If this timestamp is set then the operation has already been run or is currently running, so we don't want to start running the same operation twice. If the `started_run_at` timestamp is null then it moves on to check the `should_run_at` timestamp. If that timestamp is now or in the past, it means that the operation should be run. Of course, any operation that is deleted is not considered ready to be run.
18+
19+
So to recap, if `started_run_at` is null, if `should_run_at` is now or in the past, and the operation has not been deleted, then the operation is ready to be run.
20+
21+
## How does it consistently pick up operations when they are ready?
22+
23+
By now you might be wondering "if operations are just records in the database, how do they get into jobs when they should be run?"
24+
25+
That's a great question, because so far we've been triggering the `queue` function manually. That won't scale in a production environment though.
26+
27+
In order for operations to be useful, you're going to need some way to call the operator's `queue` function on a consistent basis. How often you call it depends on how soon you want your operations to start running after their `should_run_at` time.
28+
29+
If you call `Operator::queue()` every five minutes, you might have an operation with a `should_run_at` time of 4:01am that doesn't actually run until 4:05am. In most cases this is probably fine. It's recommended to use operations for scheduling jobs that need to happen a ways off into the future (hours to weeks, or longer), so a few minutes probably won't make or break your app. If punctuality is important you could call it every minute; if you must be more accurate than that, operations may not be the tool you're looking for.
30+
31+
## How can I call `Operator::queue()` consistently?
32+
33+
The first approach would be to write your own code that simply calls `Operator::queue()`.
34+
35+
One example of this would be to create an endpoint, which when hit queues your operations. Then you could use a service such as AWS CloudWatch Scheduled Events to hit that endpoint every nth minute.
36+
37+
If you're not interested in developing your own scheduling system and have the ability to schedule `cron` jobs on your server, you can use the `operations:queue` command included in this package.
38+
39+
You can either write your own cron that will call `php artisan operations:queue` directly, or you can use Laravel's scheduling feature by adding it to your `app\Console\Kernel@schedule` function:
40+
41+
```php
42+
protected function schedule(Schedule $schedule)
43+
{
44+
$schedule->command('operations:queue')->everyFiveMinutes();
45+
}
46+
```
47+
48+
_If you do use Laravel's scheduling function, don't forget to [set it up](https://laravel.com/docs/5.8/scheduling#introduction)._
49+
50+
Next: [Handling Failures Gracefully](/docs/failing.md)

phpunit.xml.dist

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit bootstrap="vendor/autoload.php"
3+
backupGlobals="false"
4+
backupStaticAttributes="false"
5+
colors="true"
6+
verbose="true"
7+
convertErrorsToExceptions="true"
8+
convertNoticesToExceptions="true"
9+
convertWarningsToExceptions="true"
10+
processIsolation="false"
11+
stopOnFailure="false">
12+
<testsuites>
13+
<testsuite name="Dealer Inspire Test Suite">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
<filter>
18+
<whitelist>
19+
<directory suffix=".php">src/</directory>
20+
</whitelist>
21+
</filter>
22+
<php>
23+
<env name="QUEUE_DRIVER" value="database"/>
24+
<env name="DB_CONNECTION" value="testing"/>
25+
</php>
26+
</phpunit>

0 commit comments

Comments
 (0)