├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── gush ├── gush.gemspec ├── lib ├── gush.rb └── gush │ ├── cli.rb │ ├── cli │ └── overview.rb │ ├── client.rb │ ├── configuration.rb │ ├── errors.rb │ ├── graph.rb │ ├── job.rb │ ├── json.rb │ ├── migrate │ └── 1_create_gush_workflows_created.rb │ ├── migration.rb │ ├── version.rb │ ├── worker.rb │ └── workflow.rb └── spec ├── Gushfile ├── features └── integration_spec.rb ├── gush ├── cli_spec.rb ├── client_spec.rb ├── configuration_spec.rb ├── graph_spec.rb ├── job_spec.rb ├── json_spec.rb ├── migrate │ └── 1_create_gush_workflows_created_spec.rb ├── migration_spec.rb ├── worker_spec.rb └── workflow_spec.rb ├── gush_spec.rb └── spec_helper.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | pull_request: 12 | paths-ignore: 13 | - 'README.md' 14 | push: 15 | paths-ignore: 16 | - 'README.md' 17 | 18 | jobs: 19 | test: 20 | services: 21 | redis: 22 | image: redis:alpine 23 | ports: ["6379:6379"] 24 | options: --entrypoint redis-server 25 | 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | rails_version: ['6.1.0', '7.0', '7.1.0', '7.2.2', "8.0.1"] 30 | ruby_version: ['3.1', '3.2', '3.3', '3.4'] 31 | exclude: 32 | - ruby_version: '3.4' 33 | rails_version: '6.1.0' 34 | - ruby_version: '3.1' 35 | rails_version: '8.0.1' 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Set up Ruby 39 | uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: ${{ matrix.ruby_version }} 42 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 43 | env: 44 | RAILS_VERSION: "${{ matrix.rails_version }}" 45 | - name: Install Graphviz 46 | run: sudo apt-get install graphviz 47 | - name: Run code lint 48 | run: bundle exec rubocop 49 | env: 50 | RAILS_VERSION: "${{ matrix.rails_version }}" 51 | - name: Run tests 52 | run: bundle exec rspec 53 | env: 54 | REDIS_URL: redis://localhost:6379/1 55 | RAILS_VERSION: "${{ matrix.rails_version }}" 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | workflows/ 18 | tmp 19 | test.rb 20 | /Gushfile 21 | dump.rdb 22 | .ruby-version 23 | .ruby-gemset 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | NewCops: disable 7 | 8 | Gemspec/OrderedDependencies: 9 | Enabled: false 10 | 11 | Layout/ArgumentAlignment: 12 | Enabled: false 13 | 14 | Layout/CaseIndentation: 15 | Enabled: false 16 | 17 | Layout/EmptyLinesAroundBlockBody: 18 | Enabled: false 19 | 20 | Layout/ExtraSpacing: 21 | Enabled: false 22 | 23 | Layout/FirstHashElementIndentation: 24 | Enabled: false 25 | 26 | Layout/HashAlignment: 27 | Enabled: false 28 | 29 | Layout/SpaceAroundEqualsInParameterDefault: 30 | Enabled: false 31 | 32 | Layout/SpaceAroundOperators: 33 | Enabled: false 34 | 35 | Layout/SpaceBeforeBlockBraces: 36 | Enabled: false 37 | 38 | Layout/SpaceInsideBlockBraces: 39 | Enabled: false 40 | 41 | Layout/SpaceInsideHashLiteralBraces: 42 | Enabled: false 43 | 44 | Lint/ConstantDefinitionInBlock: 45 | Exclude: 46 | - spec/**/* 47 | 48 | Lint/RedundantSplatExpansion: 49 | Enabled: false 50 | 51 | Lint/ToJSON: 52 | Enabled: false 53 | 54 | Lint/UnusedBlockArgument: 55 | Enabled: false 56 | 57 | Lint/UnusedMethodArgument: 58 | Enabled: false 59 | 60 | Lint/UselessAssignment: 61 | Enabled: false 62 | 63 | Metrics/AbcSize: 64 | Enabled: false 65 | 66 | Metrics/BlockLength: 67 | Enabled: false 68 | 69 | Metrics/ClassLength: 70 | Enabled: false 71 | 72 | Metrics/CyclomaticComplexity: 73 | Enabled: false 74 | 75 | Metrics/MethodLength: 76 | Enabled: false 77 | 78 | Naming/MemoizedInstanceVariableName: 79 | Enabled: false 80 | 81 | Naming/PredicateName: 82 | Enabled: false 83 | 84 | Naming/RescuedExceptionsVariableName: 85 | Enabled: false 86 | 87 | Style/BlockDelimiters: 88 | Enabled: false 89 | 90 | Style/ClassVars: 91 | Enabled: false 92 | 93 | Style/CombinableLoops: 94 | Enabled: false 95 | 96 | Style/ConditionalAssignment: 97 | Enabled: false 98 | 99 | Style/Documentation: 100 | Enabled: false 101 | 102 | Style/EmptyCaseCondition: 103 | Enabled: false 104 | 105 | Style/EmptyMethod: 106 | Enabled: false 107 | 108 | Style/FrozenStringLiteralComment: 109 | Enabled: false 110 | 111 | Style/GuardClause: 112 | Enabled: false 113 | 114 | Style/HashSyntax: 115 | Enabled: false 116 | 117 | Style/IfUnlessModifier: 118 | Enabled: false 119 | 120 | Style/InverseMethods: 121 | Enabled: false 122 | 123 | Style/MethodCallWithoutArgsParentheses: 124 | Enabled: false 125 | 126 | Style/NumericLiteralPrefix: 127 | Enabled: false 128 | 129 | Style/PercentLiteralDelimiters: 130 | Enabled: false 131 | 132 | Style/RaiseArgs: 133 | Enabled: false 134 | 135 | Style/SafeNavigation: 136 | Enabled: false 137 | 138 | Style/SpecialGlobalVars: 139 | Enabled: false 140 | 141 | Style/StringLiterals: 142 | Enabled: false 143 | 144 | Style/SymbolProc: 145 | Enabled: false 146 | 147 | Style/UnlessElse: 148 | Enabled: false 149 | 150 | Style/WordArray: 151 | Enabled: false 152 | 153 | Layout/LineLength: 154 | Enabled: false 155 | 156 | RSpec/AnyInstance: 157 | Enabled: false 158 | 159 | RSpec/BeEq: 160 | Enabled: false 161 | 162 | RSpec/ContextWording: 163 | Enabled: false 164 | 165 | RSpec/DescribedClass: 166 | Enabled: false 167 | 168 | RSpec/EmptyExampleGroup: 169 | Enabled: false 170 | 171 | RSpec/EmptyLineAfterExampleGroup: 172 | Enabled: false 173 | 174 | RSpec/EmptyLineAfterSubject: 175 | Enabled: false 176 | 177 | RSpec/ExampleLength: 178 | Enabled: false 179 | 180 | RSpec/ExampleWording: 181 | Enabled: false 182 | 183 | RSpec/ExpectChange: 184 | Enabled: false 185 | 186 | RSpec/HookArgument: 187 | EnforcedStyle: each 188 | 189 | RSpec/LeakyConstantDeclaration: 190 | Enabled: false 191 | 192 | RSpec/LetSetup: 193 | Enabled: false 194 | 195 | RSpec/MatchArray: 196 | Enabled: false 197 | 198 | RSpec/MessageSpies: 199 | EnforcedStyle: receive 200 | 201 | RSpec/MultipleExpectations: 202 | Enabled: false 203 | 204 | RSpec/MultipleMemoizedHelpers: 205 | Enabled: false 206 | 207 | RSpec/NamedSubject: 208 | Enabled: false 209 | 210 | RSpec/NestedGroups: 211 | Enabled: false 212 | 213 | RSpec/NotToNot: 214 | Enabled: false 215 | 216 | RSpec/PredicateMatcher: 217 | Enabled: false 218 | 219 | RSpec/ReceiveCounts: 220 | Enabled: false 221 | 222 | RSpec/SpecFilePathFormat: 223 | Enabled: false 224 | 225 | RSpec/StubbedMock: 226 | Enabled: false 227 | 228 | RSpec/SubjectStub: 229 | Enabled: false 230 | 231 | RSpec/VerifiedDoubles: 232 | Enabled: false 233 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Changes for each release are available at https://github.com/chaps-io/gush/releases 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | rails_version = ENV['RAILS_VERSION'] || '< 7.0' 5 | rails_version = "~> #{rails_version}" if rails_version =~ /^\d/ 6 | gem 'activejob', rails_version 7 | 8 | platforms :mri, :ruby do 9 | gem 'yajl-ruby' 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Chaps sp. z o.o. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gush 2 | 3 | ![Gem Version](https://img.shields.io/gem/v/gush) 4 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/chaps-io/gush/ruby.yml) 5 | 6 | 7 | Gush is a parallel workflow runner using only Redis as storage and [ActiveJob](http://guides.rubyonrails.org/v4.2/active_job_basics.html#introduction) for scheduling and executing jobs. 8 | 9 | ## Theory 10 | 11 | Gush relies on directed acyclic graphs to store dependencies, see [Parallelizing Operations With Dependencies](https://msdn.microsoft.com/en-us/magazine/dd569760.aspx) by Stephen Toub to learn more about this method. 12 | 13 | ## **WARNING - version notice** 14 | 15 | This README is about the latest `master` code, which might differ from what is released on RubyGems. See tags to browse previous READMEs. 16 | 17 | ## Installation 18 | 19 | ### 1. Add `gush` to Gemfile 20 | 21 | ```ruby 22 | gem 'gush', '~> 4.2' 23 | ``` 24 | 25 | ### 2. Create `Gushfile` 26 | 27 | When using Gush and its CLI commands you need a `Gushfile` in the root directory. 28 | `Gushfile` should require all your workflows and jobs. 29 | 30 | #### Ruby on Rails 31 | 32 | For RoR it is enough to require the full environment: 33 | 34 | ```ruby 35 | require_relative './config/environment.rb' 36 | ``` 37 | 38 | and make sure your jobs and workflows are correctly loaded by adding their directories to autoload_paths, inside `config/application.rb`: 39 | 40 | ```ruby 41 | config.autoload_paths += ["#{Rails.root}/app/jobs", "#{Rails.root}/app/workflows"] 42 | ``` 43 | 44 | #### Ruby 45 | 46 | Simply require any jobs and workflows manually in `Gushfile`: 47 | 48 | ```ruby 49 | require_relative 'lib/workflows/example_workflow.rb' 50 | require_relative 'lib/jobs/some_job.rb' 51 | require_relative 'lib/jobs/some_other_job.rb' 52 | ``` 53 | 54 | 55 | ## Example 56 | 57 | The DSL for defining jobs consists of a single `run` method. 58 | Here is a complete example of a workflow you can create: 59 | 60 | ```ruby 61 | # app/workflows/sample_workflow.rb 62 | class SampleWorkflow < Gush::Workflow 63 | def configure(url_to_fetch_from) 64 | run FetchJob1, params: { url: url_to_fetch_from } 65 | run FetchJob2, params: { some_flag: true, url: 'http://url.com' } 66 | 67 | run PersistJob1, after: FetchJob1 68 | run PersistJob2, after: FetchJob2 69 | 70 | run Normalize, 71 | after: [PersistJob1, PersistJob2], 72 | before: Index 73 | 74 | run Index 75 | end 76 | end 77 | ``` 78 | 79 | and this is how the graph will look like: 80 | 81 | ```mermaid 82 | graph TD 83 | A{Start} --> B[FetchJob1] 84 | A --> C[FetchJob2] 85 | B --> D[PersistJob1] 86 | C --> E[PersistJob2] 87 | D --> F[NormalizeJob] 88 | E --> F 89 | F --> G[IndexJob] 90 | G --> H{Finish} 91 | ``` 92 | 93 | 94 | ## Defining workflows 95 | 96 | Let's start with the simplest workflow possible, consisting of a single job: 97 | 98 | ```ruby 99 | class SimpleWorkflow < Gush::Workflow 100 | def configure 101 | run DownloadJob 102 | end 103 | end 104 | ``` 105 | 106 | Of course having a workflow with only a single job does not make sense, so it's time to define dependencies: 107 | 108 | ```ruby 109 | class SimpleWorkflow < Gush::Workflow 110 | def configure 111 | run DownloadJob 112 | run SaveJob, after: DownloadJob 113 | end 114 | end 115 | ``` 116 | 117 | We just told Gush to execute `SaveJob` right after `DownloadJob` finishes **successfully**. 118 | 119 | But what if your job must have multiple dependencies? That's easy, just provide an array to the `after` attribute: 120 | 121 | ```ruby 122 | class SimpleWorkflow < Gush::Workflow 123 | def configure 124 | run FirstDownloadJob 125 | run SecondDownloadJob 126 | 127 | run SaveJob, after: [FirstDownloadJob, SecondDownloadJob] 128 | end 129 | end 130 | ``` 131 | 132 | Now `SaveJob` will only execute after both its parents finish without errors. 133 | 134 | With this simple syntax you can build any complex workflows you can imagine! 135 | 136 | #### Alternative way 137 | 138 | `run` method also accepts `before:` attribute to define the opposite association. So we can write the same workflow as above, but like this: 139 | 140 | ```ruby 141 | class SimpleWorkflow < Gush::Workflow 142 | def configure 143 | run FirstDownloadJob, before: SaveJob 144 | run SecondDownloadJob, before: SaveJob 145 | 146 | run SaveJob 147 | end 148 | end 149 | ``` 150 | 151 | You can use whatever way you find more readable or even both at once :) 152 | 153 | ### Passing arguments to workflows 154 | 155 | Workflows can accept any primitive arguments in their constructor, which then will be available in your 156 | `configure` method. 157 | 158 | Let's assume we are writing a book publishing workflow which needs to know where the PDF of the book is and under what ISBN it will be released: 159 | 160 | ```ruby 161 | class PublishBookWorkflow < Gush::Workflow 162 | def configure(url, isbn, publish: false) 163 | run FetchBook, params: { url: url } 164 | if publish 165 | run PublishBook, params: { book_isbn: isbn }, after: FetchBook 166 | end 167 | end 168 | end 169 | ``` 170 | 171 | and then create your workflow with those arguments: 172 | 173 | ```ruby 174 | PublishBookWorkflow.create("http://url.com/book.pdf", "978-0470081204", publish: true) 175 | ``` 176 | 177 | and that's basically it for defining workflows, see below on how to define jobs: 178 | 179 | ## Defining jobs 180 | 181 | The simplest job is a class inheriting from `Gush::Job` and responding to `perform` method. Much like any other ActiveJob class. 182 | 183 | ```ruby 184 | class FetchBook < Gush::Job 185 | def perform 186 | # do some fetching from remote APIs 187 | end 188 | end 189 | ``` 190 | 191 | But what about those params we passed in the previous step? 192 | 193 | ## Passing parameters into jobs 194 | 195 | To do that, simply provide a `params:` attribute with a hash of parameters you'd like to have available inside the `perform` method of the job. 196 | 197 | So, inside workflow: 198 | 199 | ```ruby 200 | (...) 201 | run FetchBook, params: {url: "http://url.com/book.pdf"} 202 | (...) 203 | ``` 204 | 205 | and within the job we can access them like this: 206 | 207 | ```ruby 208 | class FetchBook < Gush::Job 209 | def perform 210 | # you can access `params` method here, for example: 211 | 212 | params #=> {url: "http://url.com/book.pdf"} 213 | end 214 | end 215 | ``` 216 | 217 | ## Executing workflows 218 | 219 | Now that we have defined our workflow and its jobs, we can use it: 220 | 221 | ### 1. Start background worker process 222 | 223 | **Important**: The command to start background workers depends on the backend you chose for ActiveJob. 224 | For example, in case of Sidekiq this would be: 225 | 226 | ``` 227 | bundle exec sidekiq -q gush 228 | ``` 229 | 230 | **[Click here to see backends section in official ActiveJob documentation about configuring backends](http://guides.rubyonrails.org/active_job_basics.html#backends)** 231 | 232 | **Hint**: gush uses `gush` queue name by default. Keep that in mind, because some backends (like Sidekiq) will only run jobs from explicitly stated queues. 233 | 234 | 235 | ### 2. Create the workflow instance 236 | 237 | ```ruby 238 | flow = PublishBookWorkflow.create("http://url.com/book.pdf", "978-0470081204") 239 | ``` 240 | 241 | ### 3. Start the workflow 242 | 243 | ```ruby 244 | flow.start! 245 | ``` 246 | 247 | Now Gush will start processing jobs in the background using ActiveJob and your chosen backend. 248 | 249 | ### 4. Monitor its progress: 250 | 251 | ```ruby 252 | flow.reload 253 | flow.status 254 | #=> :running|:finished|:failed 255 | ``` 256 | 257 | `reload` is needed to see the latest status, since workflows are updated asynchronously. 258 | 259 | ## Loading workflows 260 | 261 | ### Finding a workflow by id 262 | 263 | ``` 264 | flow = Workflow.find(id) 265 | ``` 266 | 267 | ### Paging through workflows 268 | 269 | To get workflows with pagination, use start and stop (inclusive) index values: 270 | 271 | ``` 272 | flows = Workflow.page(0, 99) 273 | ``` 274 | 275 | Or in reverse order: 276 | 277 | ``` 278 | flows = Workflow.page(0, 99, order: :desc) 279 | ``` 280 | 281 | ## Advanced features 282 | 283 | ### Global parameters for jobs 284 | 285 | Workflows can accept a hash of `globals` that are automatically forwarded as parameters to all jobs. 286 | 287 | This is useful to have common functionality across workflow and job classes, such as tracking the creator id for all instances: 288 | 289 | ```ruby 290 | class SimpleWorkflow < Gush::Workflow 291 | def configure(url_to_fetch_from) 292 | run DownloadJob, params: { url: url_to_fetch_from } 293 | end 294 | end 295 | 296 | flow = SimpleWorkflow.create('http://foo.com', globals: { creator_id: 123 }) 297 | flow.globals 298 | => {:creator_id=>123} 299 | flow.jobs.first.params 300 | => {:creator_id=>123, :url=>"http://foo.com"} 301 | ``` 302 | 303 | **Note:** job params with the same key as globals will take precedence over the globals. 304 | 305 | 306 | ### Pipelining 307 | 308 | Gush offers a useful tool to pass results of a job to its dependencies, so they can act differently. 309 | 310 | **Example:** 311 | 312 | Let's assume you have two jobs, `DownloadVideo`, `EncodeVideo`. 313 | The latter needs to know where the first one saved the file to be able to open it. 314 | 315 | 316 | ```ruby 317 | class DownloadVideo < Gush::Job 318 | def perform 319 | downloader = VideoDownloader.fetch("http://youtube.com/?v=someytvideo") 320 | 321 | output(downloader.file_path) 322 | end 323 | end 324 | ``` 325 | 326 | `output` method is used to ouput data from the job to all dependant jobs. 327 | 328 | Now, since `DownloadVideo` finished and its dependant job `EncodeVideo` started, we can access that payload inside it: 329 | 330 | ```ruby 331 | class EncodeVideo < Gush::Job 332 | def perform 333 | video_path = payloads.first[:output] 334 | end 335 | end 336 | ``` 337 | 338 | `payloads` is an array containing outputs from all ancestor jobs. So for our `EncodeVideo` job from above, the array will look like: 339 | 340 | 341 | ```ruby 342 | [ 343 | { 344 | id: "DownloadVideo-41bfb730-b49f-42ac-a808-156327989294" # unique id of the ancestor job 345 | class: "DownloadVideo", 346 | output: "https://s3.amazonaws.com/somebucket/downloaded-file.mp4" #the payload returned by DownloadVideo job using `output()` method 347 | } 348 | ] 349 | ``` 350 | 351 | **Note:** Keep in mind that payloads can only contain data which **can be serialized as JSON**, because that's how Gush stores them internally. 352 | 353 | ### Dynamic workflows 354 | 355 | There might be a case when you have to construct the workflow dynamically depending on the input. 356 | 357 | As an example, let's write a workflow which accepts an array of users and has to send an email to each one. Additionally after it sends the e-mail to every user, it also has to notify the admin about finishing. 358 | 359 | 360 | ```ruby 361 | 362 | class NotifyWorkflow < Gush::Workflow 363 | def configure(user_ids) 364 | notification_jobs = user_ids.map do |user_id| 365 | run NotificationJob, params: {user_id: user_id} 366 | end 367 | 368 | run AdminNotificationJob, after: notification_jobs 369 | end 370 | end 371 | ``` 372 | 373 | We can achieve that because `run` method returns the id of the created job, which we can use for chaining dependencies. 374 | 375 | Now, when we create the workflow like this: 376 | 377 | ```ruby 378 | flow = NotifyWorkflow.create([54, 21, 24, 154, 65]) # 5 user ids as an argument 379 | ``` 380 | 381 | it will generate a workflow with 5 `NotificationJob`s and one `AdminNotificationJob` which will depend on all of them: 382 | 383 | 384 | ```mermaid 385 | graph TD 386 | A{Start} --> B[NotificationJob] 387 | A --> C[NotificationJob] 388 | A --> D[NotificationJob] 389 | A --> E[NotificationJob] 390 | A --> F[NotificationJob] 391 | B --> G[AdminNotificationJob] 392 | C --> G 393 | D --> G 394 | E --> G 395 | F --> G 396 | G --> H{Finish} 397 | ``` 398 | 399 | ### Dynamic queue for jobs 400 | 401 | There might be a case you want to configure different jobs in the workflow using different queues. Based on the above the example, we want to config `AdminNotificationJob` to use queue `admin` and `NotificationJob` use queue `user`. 402 | 403 | ```ruby 404 | 405 | class NotifyWorkflow < Gush::Workflow 406 | def configure(user_ids) 407 | notification_jobs = user_ids.map do |user_id| 408 | run NotificationJob, params: {user_id: user_id}, queue: 'user' 409 | end 410 | 411 | run AdminNotificationJob, after: notification_jobs, queue: 'admin' 412 | end 413 | end 414 | ``` 415 | 416 | ### Dynamic waitable time for jobs 417 | 418 | There might be a case you want to configure a job to be executed after a time. Based on above example, we want to configure `AdminNotificationJob` to be executed after 5 seconds. 419 | 420 | ```ruby 421 | 422 | class NotifyWorkflow < Gush::Workflow 423 | def configure(user_ids) 424 | notification_jobs = user_ids.map do |user_id| 425 | run NotificationJob, params: {user_id: user_id}, queue: 'user' 426 | end 427 | 428 | run AdminNotificationJob, after: notification_jobs, queue: 'admin', wait: 5.seconds 429 | end 430 | end 431 | ``` 432 | 433 | ### Customization of ActiveJob enqueueing 434 | 435 | There might be a case when you want to customize enqueing a job with more than just the above two options (`queue` and `wait`). 436 | 437 | To pass additional options to `ActiveJob.set`, override `Job#worker_options`, e.g.: 438 | 439 | ```ruby 440 | 441 | class ScheduledJob < Gush::Job 442 | 443 | def worker_options 444 | super.merge(wait_until: Time.at(params[:start_at])) 445 | end 446 | 447 | end 448 | ``` 449 | 450 | Or to entirely customize the ActiveJob integration, override `Job#enqueue_worker!`, e.g.: 451 | 452 | ```ruby 453 | 454 | class SynchronousJob < Gush::Job 455 | 456 | def enqueue_worker!(options = {}) 457 | Gush::Worker.perform_now(workflow_id, name) 458 | end 459 | 460 | end 461 | ``` 462 | 463 | 464 | ## Command line interface (CLI) 465 | 466 | ### Checking status 467 | 468 | - of a specific workflow: 469 | 470 | ``` 471 | bundle exec gush show 472 | ``` 473 | 474 | - of a page of workflows: 475 | 476 | ``` 477 | bundle exec gush list 478 | ``` 479 | 480 | - of the most recent 100 workflows 481 | 482 | ``` 483 | bundle exec gush list -99 -1 484 | ``` 485 | 486 | ### Vizualizing workflows as image 487 | 488 | This requires that you have imagemagick installed on your computer: 489 | 490 | 491 | ``` 492 | bundle exec gush viz 493 | ``` 494 | 495 | ### Customizing locking options 496 | 497 | In order to prevent getting the RedisMutex::LockError error when having a large number of jobs, you can customize these 2 fields `locking_duration` and `polling_interval` as below 498 | 499 | ```ruby 500 | # config/initializers/gush.rb 501 | Gush.configure do |config| 502 | config.redis_url = "redis://localhost:6379" 503 | config.concurrency = 5 504 | config.locking_duration = 2 # how long you want to wait for the lock to be released, in seconds 505 | config.polling_interval = 0.3 # how long the polling interval should be, in seconds 506 | end 507 | ``` 508 | 509 | ### Cleaning up afterwards 510 | 511 | Running `NotifyWorkflow.create` inserts multiple keys into Redis every time it is run. This data might be useful for analysis but at a certain point it can be purged. By default gush and Redis will keep keys forever. To configure expiration you need to do two things. 512 | 513 | 1. Create an initializer that specifies `config.ttl` in seconds. Best NOT to set TTL to be too short (like minutes) but about a week in length. 514 | 515 | ```ruby 516 | # config/initializers/gush.rb 517 | Gush.configure do |config| 518 | config.redis_url = "redis://localhost:6379" 519 | config.concurrency = 5 520 | config.ttl = 3600*24*7 521 | end 522 | ``` 523 | 524 | 2. Call `Client#expire_workflows` periodically, which will clear all expired stored workflow and job data and indexes. This method can be called at any rate, but ideally should be called at least once for every 1000 workflows created. 525 | 526 | If you need more control over individual workflow expiration, you can call `flow.expire!(ttl)` with a TTL different from the Gush configuration, or with -1 to never expire the workflow. 527 | 528 | ### Avoid overlapping workflows 529 | 530 | Since we do not know how long our workflow execution will take we might want to avoid starting the next scheduled workflow iteration while the current one with same class is still running. Long term this could be moved into core library, perhaps `Workflow.find_by_class(klass)` 531 | 532 | ```ruby 533 | # config/initializers/gush.rb 534 | GUSH_CLIENT = Gush::Client.new 535 | # call this method before NotifyWorkflow.create 536 | def find_by_class klass 537 | GUSH_CLIENT.all_workflows.each do |flow| 538 | return true if flow.to_hash[:name] == klass && flow.running? 539 | end 540 | return false 541 | end 542 | ``` 543 | 544 | ## Gush 3.0 Migration 545 | 546 | Gush 3.0 adds indexing for fast workflow pagination and changes the mechanism for expiring workflow data from Redis. 547 | 548 | ### Migration 549 | 550 | Run `bundle exec gush migrate` after upgrading. This will update internal data structures. 551 | 552 | ### Expiration API 553 | 554 | Periodically run `Gush::Client.new.expire_workflows` to expire data. Workflows will be automatically enrolled in this expiration, so there is no longer a need to call `workflow.expire!`. 555 | 556 | 557 | ## Contributors 558 | 559 | - [Mateusz Lenik](https://github.com/mlen) 560 | - [Michał Krzyżanowski](https://github.com/krzyzak) 561 | - [Maciej Nowak](https://github.com/keqi) 562 | - [Maciej Kołek](https://github.com/ferusinfo) 563 | 564 | ## Contributing 565 | 566 | 1. Fork it ( http://github.com/chaps-io/gush/fork ) 567 | 2. Create your feature branch (`git checkout -b my-new-feature`) 568 | 3. Commit your changes (`git commit -am 'Add some feature'`) 569 | 4. Push to the branch (`git push origin my-new-feature`) 570 | 5. Create new Pull Request 571 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/gush: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "pathname" 3 | require "bundler" 4 | Bundler.require 5 | 6 | bin_file = Pathname.new(__FILE__).realpath 7 | # add self to libpath 8 | $:.unshift File.expand_path("../../lib", bin_file) 9 | 10 | require 'gush' 11 | 12 | begin 13 | Gush::CLI.start(ARGV) 14 | rescue Gush::WorkflowNotFound 15 | puts Paint["Workflow not found", :red] 16 | rescue Gush::DependencyLevelTooDeep 17 | puts Paint["Dependency level too deep. Perhaps you have a dependency cycle?", :red] 18 | end 19 | -------------------------------------------------------------------------------- /gush.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require_relative 'lib/gush/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "gush" 8 | spec.version = Gush::VERSION 9 | spec.authors = ["Piotrek Okoński", "Michał Krzyżanowski"] 10 | spec.email = ["piotrek@okonski.org", "michal.krzyzanowski+github@gmail.com"] 11 | spec.summary = "Fast and distributed workflow runner based on ActiveJob and Redis" 12 | spec.description = "Gush is a parallel workflow runner using Redis as storage and ActiveJob for executing jobs." 13 | spec.homepage = "https://github.com/chaps-io/gush" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = "gush" 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | spec.required_ruby_version = '>= 3.0.0' 21 | 22 | spec.add_dependency "activejob", ">= 6.1.0", "< 8.1.0" 23 | spec.add_dependency "concurrent-ruby", "~> 1.0" 24 | spec.add_dependency "multi_json", "~> 1.11" 25 | spec.add_dependency "redis", ">= 3.2", "< 6" 26 | spec.add_dependency "redis-mutex", "~> 4.0.1" 27 | spec.add_dependency "hiredis", "~> 0.6" 28 | spec.add_dependency "graphviz", "~> 1.2" 29 | spec.add_dependency "terminal-table", ">= 1.4", "< 3.1" 30 | spec.add_dependency "paint", "~> 2.2" 31 | spec.add_dependency "thor", ">= 0.19", "< 1.3" 32 | spec.add_dependency "launchy", "~> 2.4" 33 | spec.add_development_dependency "bundler" 34 | spec.add_development_dependency "rake", "~> 12" 35 | spec.add_development_dependency "rubocop", '~> 1.65.0' 36 | spec.add_development_dependency "rubocop-rake", '~> 0.6.0' 37 | spec.add_development_dependency "rubocop-rspec", '~> 3.0.3' 38 | spec.add_development_dependency "rspec", '~> 3.0' 39 | spec.add_development_dependency "pry", '~> 0.10' 40 | end 41 | -------------------------------------------------------------------------------- /lib/gush.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "graphviz" 4 | require "hiredis" 5 | require "pathname" 6 | require "redis" 7 | require "securerandom" 8 | require "multi_json" 9 | 10 | require "gush/json" 11 | require "gush/cli" 12 | require "gush/cli/overview" 13 | require "gush/graph" 14 | require "gush/client" 15 | require "gush/configuration" 16 | require "gush/errors" 17 | require "gush/job" 18 | require "gush/migration" 19 | require "gush/worker" 20 | require "gush/workflow" 21 | 22 | module Gush 23 | def self.gushfile 24 | configuration.gushfile 25 | end 26 | 27 | def self.root 28 | Pathname.new(__FILE__).parent.parent 29 | end 30 | 31 | def self.configuration 32 | @configuration ||= Configuration.new 33 | end 34 | 35 | def self.configure 36 | yield configuration 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/gush/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'terminal-table' 4 | require 'paint' 5 | require 'thor' 6 | require 'launchy' 7 | 8 | module Gush 9 | class CLI < Thor 10 | class_option :gushfile, desc: "configuration file to use", aliases: "-f" 11 | class_option :redis, desc: "Redis URL to use", aliases: "-r" 12 | class_option :namespace, desc: "namespace to run jobs in", aliases: "-n" 13 | 14 | def initialize(*) 15 | super 16 | Gush.configure do |config| 17 | config.gushfile = options.fetch("gushfile", config.gushfile) 18 | config.concurrency = options.fetch("concurrency", config.concurrency) 19 | config.redis_url = options.fetch("redis", config.redis_url) 20 | config.namespace = options.fetch("namespace", config.namespace) 21 | config.ttl = options.fetch("ttl", config.ttl) 22 | config.locking_duration = options.fetch("locking_duration", config.locking_duration) 23 | config.polling_interval = options.fetch("polling_interval", config.polling_interval) 24 | end 25 | load_gushfile 26 | end 27 | 28 | desc "create WORKFLOW_CLASS", "Registers new workflow" 29 | def create(name) 30 | workflow = client.create_workflow(name) 31 | puts "Workflow created with id: #{workflow.id}" 32 | puts "Start it with command: gush start #{workflow.id}" 33 | end 34 | 35 | desc "start WORKFLOW_ID [ARG ...]", "Starts Workflow with given ID" 36 | def start(*args) 37 | id = args.shift 38 | workflow = client.find_workflow(id) 39 | client.start_workflow(workflow, args) 40 | end 41 | 42 | desc "create_and_start WORKFLOW_CLASS [ARG ...]", "Create and instantly start the new workflow" 43 | def create_and_start(name, *args) 44 | workflow = client.create_workflow(name) 45 | client.start_workflow(workflow.id, args) 46 | puts "Created and started workflow with id: #{workflow.id}" 47 | end 48 | 49 | desc "stop WORKFLOW_ID", "Stops Workflow with given ID" 50 | def stop(*args) 51 | id = args.shift 52 | client.stop_workflow(id) 53 | end 54 | 55 | desc "show WORKFLOW_ID", "Shows details about workflow with given ID" 56 | option :skip_overview, type: :boolean 57 | option :skip_jobs, type: :boolean 58 | option :jobs, default: :all 59 | def show(workflow_id) 60 | workflow = client.find_workflow(workflow_id) 61 | 62 | display_overview_for(workflow) unless options[:skip_overview] 63 | 64 | display_jobs_list_for(workflow, options[:jobs]) unless options[:skip_jobs] 65 | end 66 | 67 | desc "rm WORKFLOW_ID", "Delete workflow with given ID" 68 | def rm(workflow_id) 69 | workflow = client.find_workflow(workflow_id) 70 | client.destroy_workflow(workflow) 71 | end 72 | 73 | desc "list START STOP", "Lists workflows from START index through STOP index with their statuses" 74 | option :start, type: :numeric, default: nil 75 | option :stop, type: :numeric, default: nil 76 | def list(start=nil, stop=nil) 77 | workflows = client.workflow_ids(start, stop).map do |id| 78 | client.find_workflow(id) 79 | end 80 | 81 | rows = workflows.map do |workflow| 82 | [workflow.id, (Time.at(workflow.started_at) if workflow.started_at), workflow.class, {alignment: :center, value: status_for(workflow)}] 83 | end 84 | headers = [ 85 | {alignment: :center, value: 'id'}, 86 | {alignment: :center, value: 'started at'}, 87 | {alignment: :center, value: 'name'}, 88 | {alignment: :center, value: 'status'} 89 | ] 90 | puts Terminal::Table.new(headings: headers, rows: rows) 91 | end 92 | 93 | desc "viz {WORKFLOW_CLASS|WORKFLOW_ID}", "Displays graph, visualising job dependencies" 94 | option :filename, type: :string, default: nil 95 | option :open, type: :boolean, default: nil 96 | def viz(class_or_id) 97 | client 98 | 99 | begin 100 | workflow = client.find_workflow(class_or_id) 101 | rescue WorkflowNotFound 102 | workflow = nil 103 | end 104 | 105 | unless workflow 106 | begin 107 | workflow = class_or_id.constantize.new 108 | rescue NameError => e 109 | warn Paint["'#{class_or_id}' is not a valid workflow class or id", :red] 110 | exit 1 111 | end 112 | end 113 | 114 | opts = {} 115 | 116 | if options[:filename] 117 | opts[:filename], opts[:path] = File.split(options[:filename]) 118 | end 119 | 120 | graph = Graph.new(workflow, **opts) 121 | graph.viz 122 | 123 | if (options[:open].nil? && !options[:filename]) || options[:open] 124 | Launchy.open Pathname.new(graph.path).realpath.to_s 125 | end 126 | end 127 | 128 | desc "migrate", "Runs all unapplied migrations to Gush storage" 129 | def migrate 130 | Dir[File.join(__dir__, 'migrate', '*.rb')].each {|file| require file } 131 | 132 | applied = Gush::Migration.subclasses.sort(&:version).count do |klass| 133 | migration = klass.new 134 | next if migration.migrated? 135 | 136 | puts "Migrating to #{klass.name} (#{migration.version})" 137 | migration.migrate 138 | puts "== #{migration.version} #{klass.name}: migrated ===" 139 | 140 | true 141 | end 142 | 143 | puts "#{applied} #{'migrations'.pluralize(applied)} applied" 144 | end 145 | 146 | private 147 | 148 | def client 149 | @client ||= Client.new 150 | end 151 | 152 | def overview(workflow) 153 | CLI::Overview.new(workflow) 154 | end 155 | 156 | def display_overview_for(workflow) 157 | puts overview(workflow).table 158 | end 159 | 160 | def status_for(workflow) 161 | overview(workflow).status 162 | end 163 | 164 | def display_jobs_list_for(workflow, jobs) 165 | puts overview(workflow).jobs_list(jobs) 166 | end 167 | 168 | def gushfile 169 | Gush.configuration.gushfile 170 | end 171 | 172 | def load_gushfile 173 | file = client.configuration.gushfile 174 | 175 | unless gushfile.exist? 176 | raise Thor::Error, Paint["#{file} not found, please add it to your project", :red] 177 | end 178 | 179 | load file.to_s 180 | rescue LoadError 181 | raise Thor::Error, Paint["failed to require #{file}", :red] 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/gush/cli/overview.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class CLI 3 | class Overview 4 | attr_reader :workflow 5 | 6 | def initialize(workflow) 7 | @workflow = workflow 8 | end 9 | 10 | def table 11 | Terminal::Table.new(rows: rows) 12 | end 13 | 14 | def status 15 | if workflow.failed? 16 | failed_status 17 | elsif workflow.running? 18 | running_status 19 | elsif workflow.finished? 20 | Paint["done", :green] 21 | elsif workflow.stopped? 22 | Paint["stopped", :red] 23 | else 24 | Paint["ready to start", :blue] 25 | end 26 | end 27 | 28 | def jobs_list(jobs) 29 | "\nJobs list:\n".tap do |output| 30 | jobs_by_type(jobs).each do |job| 31 | output << job_to_list_element(job) 32 | end 33 | end 34 | end 35 | 36 | private 37 | 38 | def rows 39 | [].tap do |rows| 40 | columns.each_pair do |name, value| 41 | rows << [{alignment: :center, value: name}, value] 42 | rows << :separator if name != "Status" 43 | end 44 | end 45 | end 46 | 47 | def columns 48 | { 49 | "ID" => workflow.id, 50 | "Name" => workflow.class.to_s, 51 | "Jobs" => workflow.jobs.count, 52 | "Failed jobs" => Paint[failed_jobs_count, :red], 53 | "Succeeded jobs" => Paint[succeeded_jobs_count, :green], 54 | "Enqueued jobs" => Paint[enqueued_jobs_count, :yellow], 55 | "Running jobs" => Paint[running_jobs_count, :blue], 56 | "Remaining jobs" => remaining_jobs_count, 57 | "Started at" => started_at, 58 | "Status" => status 59 | } 60 | end 61 | 62 | def running_status 63 | finished = succeeded_jobs_count.to_i 64 | status = Paint["running", :yellow] 65 | status += "\n#{finished}/#{total_jobs_count} [#{(finished*100)/total_jobs_count}%]" 66 | end 67 | 68 | def started_at 69 | Time.at(workflow.started_at) if workflow.started_at 70 | end 71 | 72 | def failed_status 73 | status = Paint["failed", :red] 74 | status += "\n#{failed_job} failed" 75 | end 76 | 77 | def job_to_list_element(job) 78 | name = job.name 79 | case 80 | when job.failed? 81 | "[✗] #{Paint[name, :red]} \n" 82 | when job.finished? 83 | "[✓] #{Paint[name, :green]} \n" 84 | when job.enqueued? 85 | "[•] #{Paint[name, :yellow]} \n" 86 | when job.running? 87 | "[•] #{Paint[name, :blue]} \n" 88 | else 89 | "[ ] #{name} \n" 90 | end 91 | end 92 | 93 | def jobs_by_type(type) 94 | return sorted_jobs if type == :all 95 | 96 | jobs.select{|j| j.public_send("#{type}?") } 97 | end 98 | 99 | def sorted_jobs 100 | workflow.jobs.sort_by do |job| 101 | case 102 | when job.failed? 103 | 0 104 | when job.finished? 105 | 1 106 | when job.enqueued? 107 | 2 108 | when job.running? 109 | 3 110 | else 111 | 4 112 | end 113 | end 114 | end 115 | 116 | def failed_job 117 | workflow.jobs.find(&:failed?).name 118 | end 119 | 120 | def total_jobs_count 121 | workflow.jobs.count 122 | end 123 | 124 | def failed_jobs_count 125 | workflow.jobs.count(&:failed?).to_s 126 | end 127 | 128 | def succeeded_jobs_count 129 | workflow.jobs.count(&:succeeded?).to_s 130 | end 131 | 132 | def enqueued_jobs_count 133 | workflow.jobs.count(&:enqueued?).to_s 134 | end 135 | 136 | def running_jobs_count 137 | workflow.jobs.count(&:running?).to_s 138 | end 139 | 140 | def remaining_jobs_count 141 | workflow.jobs.count{|j| [j.finished?, j.failed?, j.enqueued?].none? }.to_s 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/gush/client.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'concurrent-ruby' 3 | 4 | module Gush 5 | class Client 6 | attr_reader :configuration 7 | 8 | @@redis_connection = Concurrent::ThreadLocalVar.new(nil) 9 | 10 | def self.redis_connection(config) 11 | cached = (@@redis_connection.value ||= { url: config.redis_url, connection: nil }) 12 | return cached[:connection] if !cached[:connection].nil? && config.redis_url == cached[:url] 13 | 14 | Redis.new(url: config.redis_url).tap do |instance| 15 | RedisClassy.redis = instance 16 | @@redis_connection.value = { url: config.redis_url, connection: instance } 17 | end 18 | end 19 | 20 | def initialize(config = Gush.configuration) 21 | @configuration = config 22 | end 23 | 24 | def configure 25 | yield configuration 26 | end 27 | 28 | def create_workflow(name) 29 | begin 30 | name.constantize.create 31 | rescue NameError 32 | raise WorkflowNotFound.new("Workflow with given name doesn't exist") 33 | end 34 | flow 35 | end 36 | 37 | def start_workflow(workflow, job_names = []) 38 | workflow.mark_as_started 39 | persist_workflow(workflow) 40 | 41 | jobs = if job_names.empty? 42 | workflow.initial_jobs 43 | else 44 | job_names.map {|name| workflow.find_job(name) } 45 | end 46 | 47 | jobs.each do |job| 48 | enqueue_job(workflow.id, job) 49 | end 50 | end 51 | 52 | def stop_workflow(id) 53 | workflow = find_workflow(id) 54 | workflow.mark_as_stopped 55 | persist_workflow(workflow) 56 | end 57 | 58 | def next_free_job_id(workflow_id, job_klass) 59 | job_id = nil 60 | 61 | loop do 62 | job_id = SecureRandom.uuid 63 | available = !redis.hexists("gush.jobs.#{workflow_id}.#{job_klass}", job_id) 64 | 65 | break if available 66 | end 67 | 68 | job_id 69 | end 70 | 71 | def next_free_workflow_id 72 | id = nil 73 | loop do 74 | id = SecureRandom.uuid 75 | available = !redis.exists?("gush.workflows.#{id}") 76 | 77 | break if available 78 | end 79 | 80 | id 81 | end 82 | 83 | # Returns the specified range of workflow ids, sorted by created timestamp. 84 | # 85 | # @param start, stop [Integer] see https://redis.io/docs/latest/commands/zrange/#index-ranges 86 | # for details on the start and stop parameters. 87 | # @param by_ts [Boolean] if true, start and stop are treated as timestamps 88 | # rather than as element indexes, which allows the workflows to be indexed 89 | # by created timestamp 90 | # @param order [Symbol] if :asc, finds ids in ascending created timestamp; 91 | # if :desc, finds ids in descending created timestamp 92 | # @returns [Array] array of workflow ids 93 | def workflow_ids(start=nil, stop=nil, by_ts: false, order: :asc) 94 | start ||= 0 95 | stop ||= 99 96 | 97 | redis.zrange( 98 | "gush.idx.workflows.created_at", 99 | start, 100 | stop, 101 | by_score: by_ts, 102 | rev: order&.to_sym == :desc 103 | ) 104 | end 105 | 106 | def workflows(start=nil, stop=nil, **kwargs) 107 | workflow_ids(start, stop, **kwargs).map { |id| find_workflow(id) } 108 | end 109 | 110 | def workflows_count 111 | redis.zcard('gush.idx.workflows.created_at') 112 | end 113 | 114 | # Deprecated. 115 | # 116 | # This method is not performant when there are a large number of workflows 117 | # or when the redis keyspace is large. Use workflows instead with pagination. 118 | def all_workflows 119 | redis.scan_each(match: "gush.workflows.*").map do |key| 120 | id = key.sub("gush.workflows.", "") 121 | find_workflow(id) 122 | end 123 | end 124 | 125 | def find_workflow(id) 126 | data = redis.get("gush.workflows.#{id}") 127 | 128 | unless data.nil? 129 | hash = Gush::JSON.decode(data, symbolize_keys: true) 130 | 131 | if hash[:job_klasses] 132 | keys = hash[:job_klasses].map { |klass| "gush.jobs.#{id}.#{klass}" } 133 | else 134 | # For backwards compatibility, get job keys via a full keyspace scan 135 | keys = redis.scan_each(match: "gush.jobs.#{id}.*") 136 | end 137 | 138 | nodes = keys.each_with_object([]) do |key, array| 139 | array.concat(redis.hvals(key).map { |json| Gush::JSON.decode(json, symbolize_keys: true) }) 140 | end 141 | 142 | workflow_from_hash(hash, nodes) 143 | else 144 | raise WorkflowNotFound.new("Workflow with given id doesn't exist") 145 | end 146 | end 147 | 148 | def persist_workflow(workflow) 149 | created_at = Time.now.to_f 150 | added = redis.zadd("gush.idx.workflows.created_at", created_at, workflow.id, nx: true) 151 | 152 | if added && configuration.ttl&.positive? 153 | expires_at = created_at + configuration.ttl 154 | redis.zadd("gush.idx.workflows.expires_at", expires_at, workflow.id, nx: true) 155 | end 156 | 157 | redis.set("gush.workflows.#{workflow.id}", workflow.to_json) 158 | 159 | workflow.jobs.each {|job| persist_job(workflow.id, job, expires_at: expires_at) } 160 | workflow.mark_as_persisted 161 | 162 | true 163 | end 164 | 165 | def persist_job(workflow_id, job, expires_at: nil) 166 | redis.zadd("gush.idx.jobs.expires_at", expires_at, "#{workflow_id}.#{job.klass}", nx: true) if expires_at 167 | 168 | redis.hset("gush.jobs.#{workflow_id}.#{job.klass}", job.id, job.to_json) 169 | end 170 | 171 | def find_job(workflow_id, job_name) 172 | job_name_match = /(?\w*[^-])-(?.*)/.match(job_name) 173 | 174 | data = if job_name_match 175 | find_job_by_klass_and_id(workflow_id, job_name) 176 | else 177 | find_job_by_klass(workflow_id, job_name) 178 | end 179 | 180 | return nil if data.nil? 181 | 182 | data = Gush::JSON.decode(data, symbolize_keys: true) 183 | Gush::Job.from_hash(data) 184 | end 185 | 186 | def destroy_workflow(workflow) 187 | redis.del("gush.workflows.#{workflow.id}") 188 | redis.zrem("gush.idx.workflows.created_at", workflow.id) 189 | redis.zrem("gush.idx.workflows.expires_at", workflow.id) 190 | workflow.jobs.each {|job| destroy_job(workflow.id, job) } 191 | end 192 | 193 | def destroy_job(workflow_id, job) 194 | redis.del("gush.jobs.#{workflow_id}.#{job.klass}") 195 | redis.zrem("gush.idx.jobs.expires_at", "#{workflow_id}.#{job.klass}") 196 | end 197 | 198 | def expire_workflows(expires_at=nil) 199 | expires_at ||= Time.now.to_f 200 | 201 | ids = redis.zrange("gush.idx.workflows.expires_at", "-inf", expires_at, by_score: true) 202 | return if ids.empty? 203 | 204 | redis.del(ids.map { |id| "gush.workflows.#{id}" }) 205 | redis.zrem("gush.idx.workflows.created_at", ids) 206 | redis.zrem("gush.idx.workflows.expires_at", ids) 207 | 208 | expire_jobs(expires_at) 209 | end 210 | 211 | def expire_jobs(expires_at=nil) 212 | expires_at ||= Time.now.to_f 213 | 214 | keys = redis.zrange("gush.idx.jobs.expires_at", "-inf", expires_at, by_score: true) 215 | return if keys.empty? 216 | 217 | redis.del(keys.map { |key| "gush.jobs.#{key}" }) 218 | redis.zrem("gush.idx.jobs.expires_at", keys) 219 | end 220 | 221 | def expire_workflow(workflow, ttl=nil) 222 | ttl ||= configuration.ttl 223 | 224 | if ttl&.positive? 225 | redis.zadd("gush.idx.workflows.expires_at", Time.now.to_f + ttl, workflow.id) 226 | else 227 | redis.zrem("gush.idx.workflows.expires_at", workflow.id) 228 | end 229 | 230 | workflow.jobs.each {|job| expire_job(workflow.id, job, ttl) } 231 | end 232 | 233 | def expire_job(workflow_id, job, ttl=nil) 234 | ttl ||= configuration.ttl 235 | 236 | if ttl&.positive? 237 | redis.zadd("gush.idx.jobs.expires_at", Time.now.to_f + ttl, "#{workflow_id}.#{job.klass}") 238 | else 239 | redis.zrem("gush.idx.jobs.expires_at", "#{workflow_id}.#{job.klass}") 240 | end 241 | end 242 | 243 | def enqueue_job(workflow_id, job) 244 | job.enqueue! 245 | persist_job(workflow_id, job) 246 | 247 | options = { queue: configuration.namespace }.merge(job.worker_options) 248 | job.enqueue_worker!(options) 249 | end 250 | 251 | private 252 | 253 | def find_job_by_klass_and_id(workflow_id, job_name) 254 | job_klass, job_id = job_name.split('|') 255 | 256 | redis.hget("gush.jobs.#{workflow_id}.#{job_klass}", job_id) 257 | end 258 | 259 | def find_job_by_klass(workflow_id, job_name) 260 | new_cursor, result = redis.hscan("gush.jobs.#{workflow_id}.#{job_name}", 0, count: 1) 261 | return nil if result.empty? 262 | 263 | job_id, job = *result[0] 264 | 265 | job 266 | end 267 | 268 | def workflow_from_hash(hash, nodes = []) 269 | jobs = nodes.map do |node| 270 | Gush::Job.from_hash(node) 271 | end 272 | 273 | internal_state = { 274 | persisted: true, 275 | jobs: jobs, 276 | # For backwards compatibility, setup can only be skipped for a persisted 277 | # workflow if there is no data missing from the persistence. 278 | # 2024-07-23: dependencies added to persistence 279 | skip_setup: !hash[:dependencies].nil? 280 | }.merge(hash) 281 | 282 | hash[:klass].constantize.new( 283 | *hash[:arguments], 284 | **hash[:kwargs], 285 | globals: hash[:globals], 286 | internal_state: internal_state 287 | ) 288 | end 289 | 290 | def redis 291 | self.class.redis_connection(configuration) 292 | end 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /lib/gush/configuration.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class Configuration 3 | attr_accessor :concurrency, :namespace, :redis_url, :ttl, :locking_duration, :polling_interval 4 | 5 | def self.from_json(json) 6 | new(Gush::JSON.decode(json, symbolize_keys: true)) 7 | end 8 | 9 | def initialize(hash = {}) 10 | self.concurrency = hash.fetch(:concurrency, 5) 11 | self.namespace = hash.fetch(:namespace, 'gush') 12 | self.redis_url = hash.fetch(:redis_url, 'redis://localhost:6379') 13 | self.gushfile = hash.fetch(:gushfile, 'Gushfile') 14 | self.ttl = hash.fetch(:ttl, -1) 15 | self.locking_duration = hash.fetch(:locking_duration, 2) # how long you want to wait for the lock to be released, in seconds 16 | self.polling_interval = hash.fetch(:polling_internal, 0.3) # how long the polling interval should be, in seconds 17 | end 18 | 19 | def gushfile=(path) 20 | @gushfile = Pathname(path) 21 | end 22 | 23 | def gushfile 24 | @gushfile.realpath if @gushfile.exist? 25 | end 26 | 27 | def to_hash 28 | { 29 | concurrency: concurrency, 30 | namespace: namespace, 31 | redis_url: redis_url, 32 | ttl: ttl, 33 | locking_duration: locking_duration, 34 | polling_interval: polling_interval 35 | } 36 | end 37 | 38 | def to_json 39 | Gush::JSON.encode(to_hash) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/gush/errors.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class WorkflowNotFound < StandardError; end 3 | class DependencyLevelTooDeep < StandardError; end 4 | end 5 | -------------------------------------------------------------------------------- /lib/gush/graph.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tmpdir' 4 | 5 | module Gush 6 | class Graph 7 | attr_reader :workflow, :filename, :start_node, :end_node 8 | 9 | def initialize(workflow, options = {}) 10 | @workflow = workflow 11 | @filename = options.fetch(:filename, "graph.png") 12 | @path = options.fetch(:path, Pathname.new(Dir.tmpdir).join(filename)) 13 | end 14 | 15 | def viz 16 | @graph = Graphviz::Graph.new(**graph_options) 17 | @start_node = add_node('start', shape: 'diamond', fillcolor: '#CFF09E') 18 | @end_node = add_node('end', shape: 'diamond', fillcolor: '#F56991') 19 | 20 | # First, create nodes for all jobs 21 | @job_name_to_node_map = {} 22 | workflow.jobs.each do |job| 23 | add_job_node(job) 24 | end 25 | 26 | # Next, link up the jobs with edges 27 | workflow.jobs.each do |job| 28 | link_job_edges(job) 29 | end 30 | 31 | format = 'png' 32 | file_format = path.split('.')[-1] 33 | format = file_format if file_format.length == 3 34 | 35 | Graphviz.output(@graph, path: path, format: format) 36 | end 37 | 38 | def path 39 | @path.to_s 40 | end 41 | 42 | private 43 | 44 | def add_node(name, **specific_options) 45 | @graph.add_node(name, **node_options.merge(specific_options)) 46 | end 47 | 48 | def add_job_node(job) 49 | @job_name_to_node_map[job.name] = add_node(job.name, label: node_label_for_job(job)) 50 | end 51 | 52 | def link_job_edges(job) 53 | job_node = @job_name_to_node_map[job.name] 54 | 55 | if job.incoming.empty? 56 | @start_node.connect(job_node, **edge_options) 57 | end 58 | 59 | if job.outgoing.empty? 60 | job_node.connect(@end_node, **edge_options) 61 | else 62 | job.outgoing.each do |id| 63 | outgoing_job = workflow.find_job(id) 64 | job_node.connect(@job_name_to_node_map[outgoing_job.name], **edge_options) 65 | end 66 | end 67 | end 68 | 69 | def node_label_for_job(job) 70 | job.class.to_s 71 | end 72 | 73 | def graph_options 74 | { 75 | dpi: 200, 76 | compound: true, 77 | rankdir: "LR", 78 | center: true, 79 | format: 'png' 80 | } 81 | end 82 | 83 | def node_options 84 | { 85 | shape: "ellipse", 86 | style: "filled", 87 | color: "#555555", 88 | fillcolor: "white" 89 | } 90 | end 91 | 92 | def edge_options 93 | { 94 | dir: "forward", 95 | penwidth: 1, 96 | color: "#555555" 97 | } 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/gush/job.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class Job 3 | attr_accessor :workflow_id, :incoming, :outgoing, :params, 4 | :finished_at, :failed_at, :started_at, :enqueued_at, :payloads, 5 | :klass, :queue, :wait 6 | attr_reader :id, :output_payload 7 | 8 | def initialize(opts = {}) 9 | options = opts.dup 10 | assign_variables(options) 11 | end 12 | 13 | def as_json 14 | { 15 | id: id, 16 | klass: klass.to_s, 17 | queue: queue, 18 | incoming: incoming, 19 | outgoing: outgoing, 20 | finished_at: finished_at, 21 | enqueued_at: enqueued_at, 22 | started_at: started_at, 23 | failed_at: failed_at, 24 | params: params, 25 | workflow_id: workflow_id, 26 | output_payload: output_payload, 27 | wait: wait 28 | } 29 | end 30 | 31 | def name 32 | @name ||= "#{klass}|#{id}" 33 | end 34 | 35 | def to_json(options = {}) 36 | Gush::JSON.encode(as_json) 37 | end 38 | 39 | def self.from_hash(hash) 40 | hash[:klass].constantize.new(hash) 41 | end 42 | 43 | def output(data) 44 | @output_payload = data 45 | end 46 | 47 | def perform 48 | end 49 | 50 | def start! 51 | @started_at = current_timestamp 52 | @failed_at = nil 53 | end 54 | 55 | def enqueue! 56 | @enqueued_at = current_timestamp 57 | @started_at = nil 58 | @finished_at = nil 59 | @failed_at = nil 60 | end 61 | 62 | def enqueue_worker!(options = {}) 63 | Gush::Worker.set(options).perform_later(workflow_id, name) 64 | end 65 | 66 | def worker_options 67 | { queue: queue, wait: wait }.compact 68 | end 69 | 70 | def finish! 71 | @finished_at = current_timestamp 72 | end 73 | 74 | def fail! 75 | @finished_at = @failed_at = current_timestamp 76 | end 77 | 78 | def enqueued? 79 | !enqueued_at.nil? 80 | end 81 | 82 | def finished? 83 | !finished_at.nil? 84 | end 85 | 86 | def failed? 87 | !failed_at.nil? 88 | end 89 | 90 | def succeeded? 91 | finished? && !failed? 92 | end 93 | 94 | def started? 95 | !started_at.nil? 96 | end 97 | 98 | def running? 99 | started? && !finished? 100 | end 101 | 102 | def ready_to_start? 103 | !running? && !enqueued? && !finished? && !failed? && parents_succeeded? 104 | end 105 | 106 | def parents_succeeded? 107 | !incoming.any? do |name| 108 | !client.find_job(workflow_id, name).succeeded? 109 | end 110 | end 111 | 112 | def has_no_dependencies? 113 | incoming.empty? 114 | end 115 | 116 | private 117 | 118 | def client 119 | @client ||= Client.new 120 | end 121 | 122 | def current_timestamp 123 | Time.now.to_i 124 | end 125 | 126 | def assign_variables(opts) 127 | @id = opts[:id] 128 | @incoming = opts[:incoming] || [] 129 | @outgoing = opts[:outgoing] || [] 130 | @failed_at = opts[:failed_at] 131 | @finished_at = opts[:finished_at] 132 | @started_at = opts[:started_at] 133 | @enqueued_at = opts[:enqueued_at] 134 | @params = opts[:params] || {} 135 | @klass = opts[:klass] || self.class 136 | @output_payload = opts[:output_payload] 137 | @workflow_id = opts[:workflow_id] 138 | @queue = opts[:queue] 139 | @wait = opts[:wait] 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/gush/json.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class JSON 3 | def self.encode(data) 4 | MultiJson.dump(data) 5 | end 6 | 7 | def self.decode(data, options = {}) 8 | MultiJson.load(data, options) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gush/migrate/1_create_gush_workflows_created.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class IndexWorkflowsByCreatedAtAndExpiresAt < Gush::Migration 3 | def self.version 4 | 1 5 | end 6 | 7 | def up 8 | redis.scan_each(match: "gush.workflows.*").map do |key| 9 | id = key.sub("gush.workflows.", "") 10 | workflow = client.find_workflow(id) 11 | 12 | ttl = redis.ttl(key) 13 | redis.persist(key) 14 | workflow.jobs.each { |job| redis.persist("gush.jobs.#{id}.#{job.klass}") } 15 | 16 | client.persist_workflow(workflow) 17 | client.expire_workflow(workflow, ttl.positive? ? ttl : -1) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gush/migration.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | class Migration 3 | def migrate 4 | return if migrated? 5 | 6 | up 7 | migrated! 8 | end 9 | 10 | def up 11 | # subclass responsibility 12 | raise NotImplementedError 13 | end 14 | 15 | def version 16 | self.class.version 17 | end 18 | 19 | def migrated? 20 | redis.sismember("gush.migration.schema_migrations", version) 21 | end 22 | 23 | private 24 | 25 | def migrated! 26 | redis.sadd("gush.migration.schema_migrations", version) 27 | end 28 | 29 | def client 30 | @client ||= Client.new 31 | end 32 | 33 | def redis 34 | Gush::Client.redis_connection(client.configuration) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/gush/version.rb: -------------------------------------------------------------------------------- 1 | module Gush 2 | VERSION = '4.2.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/gush/worker.rb: -------------------------------------------------------------------------------- 1 | require 'active_job' 2 | require 'redis-mutex' 3 | 4 | module Gush 5 | class Worker < ::ActiveJob::Base 6 | def perform(workflow_id, job_id) 7 | setup_job(workflow_id, job_id) 8 | 9 | if job.succeeded? 10 | # Try to enqueue outgoing jobs again because the last job has redis mutex lock error 11 | enqueue_outgoing_jobs 12 | return 13 | end 14 | 15 | job.payloads = incoming_payloads 16 | 17 | error = nil 18 | 19 | mark_as_started 20 | begin 21 | job.perform 22 | rescue StandardError => error 23 | mark_as_failed 24 | raise error 25 | else 26 | mark_as_finished 27 | enqueue_outgoing_jobs 28 | end 29 | end 30 | 31 | private 32 | 33 | attr_reader :workflow_id, :job 34 | 35 | def client 36 | @client ||= Gush::Client.new(Gush.configuration) 37 | end 38 | 39 | def configuration 40 | @configuration ||= client.configuration 41 | end 42 | 43 | def setup_job(workflow_id, job_id) 44 | @workflow_id = workflow_id 45 | @job ||= client.find_job(workflow_id, job_id) 46 | end 47 | 48 | def incoming_payloads 49 | job.incoming.map do |job_name| 50 | job = client.find_job(workflow_id, job_name) 51 | { 52 | id: job.name, 53 | class: job.klass.to_s, 54 | output: job.output_payload 55 | } 56 | end 57 | end 58 | 59 | def mark_as_finished 60 | job.finish! 61 | client.persist_job(workflow_id, job) 62 | end 63 | 64 | def mark_as_failed 65 | job.fail! 66 | client.persist_job(workflow_id, job) 67 | end 68 | 69 | def mark_as_started 70 | job.start! 71 | client.persist_job(workflow_id, job) 72 | end 73 | 74 | def elapsed(start) 75 | (Time.now - start).to_f.round(3) 76 | end 77 | 78 | def enqueue_outgoing_jobs 79 | job.outgoing.each do |job_name| 80 | RedisMutex.with_lock( 81 | "gush_enqueue_outgoing_jobs_#{workflow_id}-#{job_name}", 82 | sleep: configuration.polling_interval, 83 | block: configuration.locking_duration 84 | ) do 85 | out = client.find_job(workflow_id, job_name) 86 | 87 | if out.ready_to_start? 88 | client.enqueue_job(workflow_id, out) 89 | end 90 | end 91 | end 92 | rescue RedisMutex::LockError 93 | Worker.set(wait: 2.seconds).perform_later(workflow_id, job.name) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/gush/workflow.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module Gush 4 | class Workflow 5 | attr_accessor :jobs, :dependencies, :stopped, :persisted, :arguments, :kwargs, :globals 6 | attr_writer :id 7 | 8 | def initialize(*args, globals: nil, internal_state: {}, **kwargs) 9 | @arguments = args 10 | @kwargs = kwargs 11 | @globals = globals || {} 12 | 13 | @id = internal_state[:id] || id 14 | @jobs = internal_state[:jobs] || [] 15 | @dependencies = internal_state[:dependencies] || [] 16 | @persisted = internal_state[:persisted] || false 17 | @stopped = internal_state[:stopped] || false 18 | 19 | setup unless internal_state[:skip_setup] 20 | end 21 | 22 | def self.find(id) 23 | Gush::Client.new.find_workflow(id) 24 | end 25 | 26 | def self.page(start=0, stop=99, order: :asc) 27 | Gush::Client.new.workflows(start, stop, order: order) 28 | end 29 | 30 | def self.create(*args, **kwargs) 31 | flow = new(*args, **kwargs) 32 | flow.save 33 | flow 34 | end 35 | 36 | def continue 37 | client = Gush::Client.new 38 | failed_jobs = jobs.select(&:failed?) 39 | 40 | failed_jobs.each do |job| 41 | client.enqueue_job(id, job) 42 | end 43 | end 44 | 45 | def save 46 | persist! 47 | end 48 | 49 | def configure(*args, **kwargs) 50 | end 51 | 52 | def mark_as_stopped 53 | @stopped = true 54 | end 55 | 56 | def start! 57 | client.start_workflow(self) 58 | end 59 | 60 | def persist! 61 | client.persist_workflow(self) 62 | end 63 | 64 | def expire!(ttl=nil) 65 | client.expire_workflow(self, ttl) 66 | end 67 | 68 | def mark_as_persisted 69 | @persisted = true 70 | end 71 | 72 | def mark_as_started 73 | @stopped = false 74 | end 75 | 76 | def resolve_dependencies 77 | @dependencies.each do |dependency| 78 | from = find_job(dependency[:from]) 79 | to = find_job(dependency[:to]) 80 | 81 | to.incoming << dependency[:from] 82 | from.outgoing << dependency[:to] 83 | end 84 | end 85 | 86 | def find_job(name) 87 | match_data = /(?\w*[^-])-(?.*)/.match(name.to_s) 88 | 89 | if match_data.nil? 90 | job = jobs.find { |node| node.klass.to_s == name.to_s } 91 | else 92 | job = jobs.find { |node| node.name.to_s == name.to_s } 93 | end 94 | 95 | job 96 | end 97 | 98 | def finished? 99 | jobs.all?(&:finished?) 100 | end 101 | 102 | def started? 103 | !!started_at 104 | end 105 | 106 | def running? 107 | started? && !finished? 108 | end 109 | 110 | def failed? 111 | jobs.any?(&:failed?) 112 | end 113 | 114 | def stopped? 115 | stopped 116 | end 117 | 118 | def run(klass, opts = {}) 119 | node = klass.new({ 120 | workflow_id: id, 121 | id: client.next_free_job_id(id, klass.to_s), 122 | params: (@globals || {}).merge(opts.fetch(:params, {})), 123 | queue: opts[:queue], 124 | wait: opts[:wait] 125 | }) 126 | 127 | jobs << node 128 | 129 | deps_after = [*opts[:after]] 130 | 131 | deps_after.each do |dep| 132 | @dependencies << {from: dep.to_s, to: node.name.to_s } 133 | end 134 | 135 | deps_before = [*opts[:before]] 136 | 137 | deps_before.each do |dep| 138 | @dependencies << {from: node.name.to_s, to: dep.to_s } 139 | end 140 | 141 | node.name 142 | end 143 | 144 | def reload 145 | flow = self.class.find(id) 146 | 147 | self.jobs = flow.jobs 148 | self.stopped = flow.stopped 149 | 150 | self 151 | end 152 | 153 | def initial_jobs 154 | jobs.select(&:has_no_dependencies?) 155 | end 156 | 157 | def status 158 | case 159 | when failed? 160 | :failed 161 | when running? 162 | :running 163 | when finished? 164 | :finished 165 | when stopped? 166 | :stopped 167 | else 168 | :pending 169 | end 170 | end 171 | 172 | def started_at 173 | first_job ? first_job.started_at : nil 174 | end 175 | 176 | def finished_at 177 | last_job ? last_job.finished_at : nil 178 | end 179 | 180 | def to_hash 181 | name = self.class.to_s 182 | { 183 | name: name, 184 | id: id, 185 | arguments: @arguments, 186 | kwargs: @kwargs, 187 | globals: @globals, 188 | dependencies: @dependencies, 189 | total: jobs.count, 190 | finished: jobs.count(&:finished?), 191 | klass: name, 192 | job_klasses: jobs.map(&:class).map(&:to_s).uniq, 193 | status: status, 194 | stopped: stopped, 195 | started_at: started_at, 196 | finished_at: finished_at 197 | } 198 | end 199 | 200 | def to_json(options = {}) 201 | Gush::JSON.encode(to_hash) 202 | end 203 | 204 | def self.descendants 205 | ObjectSpace.each_object(Class).select { |klass| klass < self } 206 | end 207 | 208 | def id 209 | @id ||= client.next_free_workflow_id 210 | end 211 | 212 | private 213 | 214 | def setup 215 | configure(*@arguments, **@kwargs) 216 | resolve_dependencies 217 | end 218 | 219 | def client 220 | @client ||= Client.new 221 | end 222 | 223 | def first_job 224 | jobs.min_by{ |n| n.started_at || Time.now.to_i } 225 | end 226 | 227 | def last_job 228 | jobs.max_by{ |n| n.finished_at || 0 } if finished? 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /spec/Gushfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaps-io/gush/53c255a0b6a9e31baed3976723f5841d6d2dac68/spec/Gushfile -------------------------------------------------------------------------------- /spec/features/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'pry' 3 | 4 | describe "Workflows" do 5 | context "when all jobs finish successfuly" do 6 | it "marks workflow as completed" do 7 | flow = TestWorkflow.create 8 | 9 | ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true 10 | flow.start! 11 | ActiveJob::Base.queue_adapter.perform_enqueued_jobs = false 12 | 13 | flow = flow.reload 14 | expect(flow).to be_finished 15 | expect(flow).to_not be_failed 16 | end 17 | end 18 | 19 | context 'when one of the jobs fails initally' do 20 | it 'succeeds when the job retries' do 21 | FAIL_THEN_SUCCEED_SPY = double() 22 | allow(FAIL_THEN_SUCCEED_SPY).to receive(:foo).and_return('failure', 'success') 23 | 24 | class FailsThenSucceeds < Gush::Job 25 | def perform 26 | if FAIL_THEN_SUCCEED_SPY.foo == 'failure' 27 | raise NameError 28 | end 29 | end 30 | end 31 | 32 | class SecondChanceWorkflow < Gush::Workflow 33 | def configure 34 | run Prepare 35 | run FailsThenSucceeds, after: Prepare 36 | run NormalizeJob, after: FailsThenSucceeds 37 | end 38 | end 39 | 40 | flow = SecondChanceWorkflow.create 41 | flow.start! 42 | 43 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['Prepare'])) 44 | perform_one 45 | 46 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['FailsThenSucceeds'])) 47 | expect do 48 | perform_one 49 | end.to raise_error(NameError) 50 | 51 | expect(flow.reload).to be_failed 52 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['FailsThenSucceeds'])) 53 | 54 | # Retry the same job again, but this time succeeds 55 | perform_one 56 | 57 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['NormalizeJob'])) 58 | perform_one 59 | 60 | flow = flow.reload 61 | expect(flow).to be_finished 62 | expect(flow).to_not be_failed 63 | end 64 | end 65 | 66 | it "runs the whole workflow in proper order" do 67 | flow = TestWorkflow.create 68 | flow.start! 69 | 70 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['Prepare'])) 71 | 72 | perform_one 73 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(["FetchFirstJob", "FetchSecondJob"])) 74 | 75 | perform_one 76 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(["FetchSecondJob", "PersistFirstJob"])) 77 | 78 | perform_one 79 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(["PersistFirstJob"])) 80 | 81 | perform_one 82 | expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(["NormalizeJob"])) 83 | 84 | perform_one 85 | 86 | expect(ActiveJob::Base.queue_adapter.enqueued_jobs).to be_empty 87 | end 88 | 89 | it "passes payloads down the workflow" do 90 | class UpcaseJob < Gush::Job 91 | def perform 92 | output params[:input].upcase 93 | end 94 | end 95 | 96 | class PrefixJob < Gush::Job 97 | def perform 98 | output params[:prefix].capitalize 99 | end 100 | end 101 | 102 | class PrependJob < Gush::Job 103 | def perform 104 | string = "#{payloads.find { |j| j[:class] == 'PrefixJob'}[:output]}: #{payloads.find { |j| j[:class] == 'UpcaseJob'}[:output]}" 105 | output string 106 | end 107 | end 108 | 109 | class PayloadWorkflow < Gush::Workflow 110 | def configure 111 | run UpcaseJob, params: {input: "some text"} 112 | run PrefixJob, params: {prefix: "a prefix"} 113 | run PrependJob, after: [UpcaseJob, PrefixJob] 114 | end 115 | end 116 | 117 | flow = PayloadWorkflow.create 118 | flow.start! 119 | 120 | perform_one 121 | expect(flow.reload.find_job("UpcaseJob").output_payload).to eq("SOME TEXT") 122 | 123 | perform_one 124 | expect(flow.reload.find_job("PrefixJob").output_payload).to eq("A prefix") 125 | 126 | perform_one 127 | expect(flow.reload.find_job("PrependJob").output_payload).to eq("A prefix: SOME TEXT") 128 | end 129 | 130 | it "passes payloads from workflow that runs multiple same class jobs with nameized payloads" do 131 | class RepetitiveJob < Gush::Job 132 | def perform 133 | output params[:input] 134 | end 135 | end 136 | 137 | class SummaryJob < Gush::Job 138 | def perform 139 | output(payloads.map { |payload| payload[:output] }) 140 | end 141 | end 142 | 143 | class PayloadWorkflow < Gush::Workflow 144 | def configure 145 | jobs = [] 146 | jobs << run(RepetitiveJob, params: {input: 'first'}) 147 | jobs << run(RepetitiveJob, params: {input: 'second'}) 148 | jobs << run(RepetitiveJob, params: {input: 'third'}) 149 | run SummaryJob, after: jobs 150 | end 151 | end 152 | 153 | flow = PayloadWorkflow.create 154 | flow.start! 155 | 156 | 3.times { perform_one } 157 | 158 | outputs = flow.reload.jobs.select { |j| j.klass == 'RepetitiveJob' }.map { |j| j.output_payload } 159 | expect(outputs).to match_array(['first', 'second', 'third']) 160 | 161 | perform_one 162 | 163 | summary_job = flow.reload.jobs.find { |j| j.klass == 'SummaryJob' } 164 | expect(summary_job.output_payload).to eq(%w(first second third)) 165 | end 166 | 167 | it "does not execute `configure` on each job for huge workflows" do 168 | INTERNAL_SPY = double('spy') 169 | INTERNAL_CONFIGURE_SPY = double('configure spy') 170 | expect(INTERNAL_SPY).to receive(:some_method).exactly(110).times 171 | 172 | # One time when persisting; reloading does not call configure again 173 | expect(INTERNAL_CONFIGURE_SPY).to receive(:some_method).exactly(1).time 174 | 175 | class SimpleJob < Gush::Job 176 | def perform 177 | INTERNAL_SPY.some_method 178 | end 179 | end 180 | 181 | class GiganticWorkflow < Gush::Workflow 182 | def configure 183 | INTERNAL_CONFIGURE_SPY.some_method 184 | 185 | 10.times do 186 | main = run(SimpleJob) 187 | 10.times do 188 | run(SimpleJob, after: main) 189 | end 190 | end 191 | end 192 | end 193 | 194 | flow = GiganticWorkflow.create 195 | flow.start! 196 | 197 | 110.times do 198 | perform_one 199 | end 200 | 201 | flow = flow.reload 202 | expect(flow).to be_finished 203 | expect(flow).to_not be_failed 204 | end 205 | 206 | it 'executes job with multiple ancestors only once' do 207 | NO_DUPS_INTERNAL_SPY = double('spy') 208 | expect(NO_DUPS_INTERNAL_SPY).to receive(:some_method).exactly(1).times 209 | 210 | class FirstAncestor < Gush::Job 211 | def perform 212 | end 213 | end 214 | 215 | class SecondAncestor < Gush::Job 216 | def perform 217 | end 218 | end 219 | 220 | class FinalJob < Gush::Job 221 | def perform 222 | NO_DUPS_INTERNAL_SPY.some_method 223 | end 224 | end 225 | 226 | class NoDuplicatesWorkflow < Gush::Workflow 227 | def configure 228 | run FirstAncestor 229 | run SecondAncestor 230 | 231 | run FinalJob, after: [FirstAncestor, SecondAncestor] 232 | end 233 | end 234 | 235 | flow = NoDuplicatesWorkflow.create 236 | flow.start! 237 | 238 | 5.times do 239 | perform_one 240 | end 241 | 242 | flow = flow.reload 243 | expect(flow).to be_finished 244 | expect(flow).to_not be_failed 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /spec/gush/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Graph do 4 | 5 | describe "#create" do 6 | 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/gush/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Client do 4 | let(:client) do 5 | Gush::Client.new(Gush::Configuration.new(gushfile: GUSHFILE, redis_url: REDIS_URL)) 6 | end 7 | 8 | describe "#find_workflow" do 9 | context "when workflow doesn't exist" do 10 | it "returns raises WorkflowNotFound" do 11 | expect { 12 | client.find_workflow('nope') 13 | }.to raise_error(Gush::WorkflowNotFound) 14 | end 15 | end 16 | 17 | context "when given workflow exists" do 18 | 19 | it "returns Workflow object" do 20 | expected_workflow = TestWorkflow.create 21 | workflow = client.find_workflow(expected_workflow.id) 22 | dependencies = workflow.dependencies 23 | 24 | expect(workflow.id).to eq(expected_workflow.id) 25 | expect(workflow.persisted).to eq(true) 26 | expect(workflow.jobs.map(&:name)).to match_array(expected_workflow.jobs.map(&:name)) 27 | expect(workflow.dependencies).to eq(dependencies) 28 | end 29 | 30 | context "when workflow has parameters" do 31 | it "returns Workflow object" do 32 | expected_workflow = ParameterTestWorkflow.create(true, kwarg: 123) 33 | workflow = client.find_workflow(expected_workflow.id) 34 | 35 | expect(workflow.id).to eq(expected_workflow.id) 36 | expect(workflow.arguments).to eq([true]) 37 | expect(workflow.kwargs).to eq({ kwarg: 123 }) 38 | expect(workflow.jobs.map(&:name)).to match_array(expected_workflow.jobs.map(&:name)) 39 | end 40 | end 41 | 42 | context "when workflow has globals" do 43 | it "returns Workflow object" do 44 | expected_workflow = TestWorkflow.create(globals: { global1: 'foo' }) 45 | workflow = client.find_workflow(expected_workflow.id) 46 | 47 | expect(workflow.id).to eq(expected_workflow.id) 48 | expect(workflow.globals[:global1]).to eq('foo') 49 | end 50 | end 51 | 52 | context "when workflow was persisted without job_klasses" do 53 | it "returns Workflow object" do 54 | expected_workflow = TestWorkflow.create 55 | 56 | json = Gush::JSON.encode(expected_workflow.to_hash.except(:job_klasses)) 57 | redis.set("gush.workflows.#{expected_workflow.id}", json) 58 | 59 | workflow = client.find_workflow(expected_workflow.id) 60 | 61 | expect(workflow.id).to eq(expected_workflow.id) 62 | expect(workflow.jobs.map(&:name)).to match_array(expected_workflow.jobs.map(&:name)) 63 | end 64 | end 65 | end 66 | end 67 | 68 | describe "#start_workflow" do 69 | context "when there is wait parameter configured" do 70 | let(:freeze_time) { Time.utc(2023, 01, 21, 14, 36, 0) } 71 | 72 | it "schedules job execution" do 73 | travel_to freeze_time do 74 | workflow = WaitableTestWorkflow.create 75 | client.start_workflow(workflow) 76 | expect(Gush::Worker).to have_a_job_enqueued_at(workflow.id, job_with_id("Prepare"), 5.minutes) 77 | end 78 | end 79 | end 80 | 81 | it "enqueues next jobs from the workflow" do 82 | workflow = TestWorkflow.create 83 | expect { 84 | client.start_workflow(workflow) 85 | }.to change{ActiveJob::Base.queue_adapter.enqueued_jobs.size}.from(0).to(1) 86 | end 87 | 88 | it "removes stopped flag when the workflow is started" do 89 | workflow = TestWorkflow.create 90 | workflow.mark_as_stopped 91 | workflow.persist! 92 | expect { 93 | client.start_workflow(workflow) 94 | }.to change{client.find_workflow(workflow.id).stopped?}.from(true).to(false) 95 | end 96 | 97 | it "marks the enqueued jobs as enqueued" do 98 | workflow = TestWorkflow.create 99 | client.start_workflow(workflow) 100 | job = workflow.reload.find_job("Prepare") 101 | expect(job.enqueued?).to eq(true) 102 | end 103 | end 104 | 105 | describe "#stop_workflow" do 106 | it "marks the workflow as stopped" do 107 | workflow = TestWorkflow.create 108 | expect { 109 | client.stop_workflow(workflow.id) 110 | }.to change{client.find_workflow(workflow.id).stopped?}.from(false).to(true) 111 | end 112 | end 113 | 114 | describe "#next_free_job_id" do 115 | it "returns an id" do 116 | expect(client.next_free_job_id('123', Prepare.to_s)).to match(/^\h{8}-\h{4}-(\h{4})-\h{4}-\h{12}$/) 117 | end 118 | 119 | it "returns an id that doesn't match an existing job id" do 120 | workflow = TestWorkflow.create 121 | job = workflow.jobs.first 122 | 123 | second_try_id = '1234' 124 | allow(SecureRandom).to receive(:uuid).and_return(job.id, second_try_id) 125 | 126 | expect(client.next_free_job_id(workflow.id, job.class.to_s)).to eq(second_try_id) 127 | end 128 | end 129 | 130 | describe "#next_free_workflow_id" do 131 | it "returns an id" do 132 | expect(client.next_free_workflow_id).to match(/^\h{8}-\h{4}-(\h{4})-\h{4}-\h{12}$/) 133 | end 134 | 135 | it "returns an id that doesn't match an existing workflow id" do 136 | workflow = TestWorkflow.create 137 | 138 | second_try_id = '1234' 139 | allow(SecureRandom).to receive(:uuid).and_return(workflow.id, second_try_id) 140 | 141 | expect(client.next_free_workflow_id).to eq(second_try_id) 142 | end 143 | end 144 | 145 | describe "#persist_workflow" do 146 | it "persists JSON dump of the Workflow and its jobs" do 147 | job = double("job", to_json: 'json') 148 | workflow = double("workflow", id: 'abcd', jobs: [job, job, job], to_json: '"json"') 149 | expect(client).to receive(:persist_job).exactly(3).times.with(workflow.id, job, expires_at: nil) 150 | expect(workflow).to receive(:mark_as_persisted) 151 | client.persist_workflow(workflow) 152 | expect(redis.keys("gush.workflows.abcd").length).to eq(1) 153 | end 154 | 155 | it "sets created_at index" do 156 | workflow = double("workflow", id: 'abcd', jobs: [], to_json: '"json"') 157 | expect(workflow).to receive(:mark_as_persisted).twice 158 | 159 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 160 | travel_to(freeze_time) do 161 | client.persist_workflow(workflow) 162 | end 163 | 164 | expect(redis.zrange("gush.idx.workflows.created_at", 0, -1, with_scores: true)) 165 | .to eq([[workflow.id, freeze_time.to_f]]) 166 | 167 | # Persisting the workflow again should not affect its created_at index score 168 | client.persist_workflow(workflow) 169 | expect(redis.zrange("gush.idx.workflows.created_at", 0, -1, with_scores: true)) 170 | .to eq([[workflow.id, freeze_time.to_f]]) 171 | end 172 | 173 | it "sets expires_at index when there is a ttl configured" do 174 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 175 | 176 | workflow = double("workflow", id: 'abcd', jobs: [], to_json: '"json"') 177 | expect(workflow).to receive(:mark_as_persisted).twice 178 | 179 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 180 | travel_to(freeze_time) do 181 | client.persist_workflow(workflow) 182 | end 183 | 184 | expires_at = freeze_time + 1000 185 | expect(redis.zrange("gush.idx.workflows.expires_at", 0, -1, with_scores: true)) 186 | .to eq([[workflow.id, expires_at.to_f]]) 187 | 188 | # Persisting the workflow again should not affect its expires_at index score 189 | client.persist_workflow(workflow) 190 | expect(redis.zrange("gush.idx.workflows.expires_at", 0, -1, with_scores: true)) 191 | .to eq([[workflow.id, expires_at.to_f]]) 192 | end 193 | 194 | it "does not set expires_at index when there is no ttl configured" do 195 | workflow = double("workflow", id: 'abcd', jobs: [], to_json: '"json"') 196 | expect(workflow).to receive(:mark_as_persisted) 197 | client.persist_workflow(workflow) 198 | 199 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 200 | end 201 | 202 | it "does not set expires_at index when updating a pre-existing workflow without a ttl" do 203 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 204 | 205 | workflow = double("workflow", id: 'abcd', jobs: [], to_json: '"json"') 206 | expect(workflow).to receive(:mark_as_persisted).twice 207 | 208 | client.persist_workflow(workflow) 209 | 210 | client.expire_workflow(workflow, -1) 211 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 212 | 213 | client.persist_workflow(workflow) 214 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 215 | end 216 | 217 | it "does not change expires_at index when updating a pre-existing workflow with a non-standard ttl" do 218 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 219 | 220 | workflow = double("workflow", id: 'abcd', jobs: [], to_json: '"json"') 221 | expect(workflow).to receive(:mark_as_persisted).twice 222 | 223 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 224 | travel_to(freeze_time) do 225 | client.persist_workflow(workflow) 226 | 227 | expires_at = freeze_time.to_i + 1234 228 | client.expire_workflow(workflow, 1234) 229 | expect(redis.zscore("gush.idx.workflows.expires_at", workflow.id)).to eq(expires_at) 230 | 231 | client.persist_workflow(workflow) 232 | expect(redis.zscore("gush.idx.workflows.expires_at", workflow.id)).to eq(expires_at) 233 | end 234 | end 235 | end 236 | 237 | describe "#destroy_workflow" do 238 | it "removes all Redis keys related to the workflow" do 239 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 240 | 241 | workflow = TestWorkflow.create 242 | expect(redis.keys("gush.workflows.#{workflow.id}").length).to eq(1) 243 | expect(redis.keys("gush.jobs.#{workflow.id}.*").length).to eq(5) 244 | expect(redis.zcard("gush.idx.workflows.created_at")).to eq(1) 245 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(1) 246 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(5) 247 | 248 | client.destroy_workflow(workflow) 249 | 250 | expect(redis.keys("gush.workflows.#{workflow.id}").length).to eq(0) 251 | expect(redis.keys("gush.jobs.#{workflow.id}.*").length).to eq(0) 252 | expect(redis.zcard("gush.idx.workflows.created_at")).to eq(0) 253 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 254 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(0) 255 | end 256 | end 257 | 258 | describe "#expire_workflows" do 259 | it "removes auto-expired workflows" do 260 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 261 | 262 | workflow = TestWorkflow.create 263 | 264 | # before workflow's expiration time 265 | client.expire_workflows 266 | 267 | expect(redis.keys("gush.workflows.*").length).to eq(1) 268 | 269 | # after workflow's expiration time 270 | client.expire_workflows(Time.now.to_f + 1001) 271 | 272 | expect(redis.keys("gush.workflows.#{workflow.id}").length).to eq(0) 273 | expect(redis.keys("gush.jobs.#{workflow.id}.*").length).to eq(0) 274 | expect(redis.zcard("gush.idx.workflows.created_at")).to eq(0) 275 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 276 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(0) 277 | end 278 | 279 | it "removes manually-expired workflows" do 280 | workflow = TestWorkflow.create 281 | 282 | # workflow hasn't been expired 283 | client.expire_workflows(Time.now.to_f + 100_000) 284 | 285 | expect(redis.keys("gush.workflows.*").length).to eq(1) 286 | 287 | client.expire_workflow(workflow, 10) 288 | 289 | # after workflow's expiration time 290 | client.expire_workflows(Time.now.to_f + 20) 291 | 292 | expect(redis.keys("gush.workflows.#{workflow.id}").length).to eq(0) 293 | expect(redis.keys("gush.jobs.#{workflow.id}.*").length).to eq(0) 294 | expect(redis.zcard("gush.idx.workflows.created_at")).to eq(0) 295 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 296 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(0) 297 | end 298 | end 299 | 300 | describe "#expire_workflow" do 301 | let(:ttl) { 2000 } 302 | 303 | it "sets an expiration time for the workflow" do 304 | workflow = TestWorkflow.create 305 | 306 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 307 | expires_at = freeze_time.to_f + ttl 308 | travel_to(freeze_time) do 309 | client.expire_workflow(workflow, ttl) 310 | end 311 | 312 | expect(redis.zscore("gush.idx.workflows.expires_at", workflow.id)).to eq(expires_at) 313 | 314 | workflow.jobs.each do |job| 315 | expect(redis.zscore("gush.idx.jobs.expires_at", "#{workflow.id}.#{job.klass}")).to eq(expires_at) 316 | end 317 | end 318 | 319 | it "clears an expiration time for the workflow when given -1" do 320 | workflow = TestWorkflow.create 321 | 322 | client.expire_workflow(workflow, 100) 323 | expect(redis.zscore("gush.idx.workflows.expires_at", workflow.id)).to be > 0 324 | 325 | client.expire_workflow(workflow, -1) 326 | expect(redis.zscore("gush.idx.workflows.expires_at", workflow.id)).to eq(nil) 327 | 328 | workflow.jobs.each do |job| 329 | expect(redis.zscore("gush.idx.jobs.expires_at", "#{workflow.id}.#{job.klass}")).to eq(nil) 330 | end 331 | end 332 | end 333 | 334 | describe "#persist_job" do 335 | it "persists JSON dump of the job in Redis" do 336 | 337 | job = BobJob.new(name: 'bob', id: 'abcd123') 338 | 339 | client.persist_job('deadbeef', job) 340 | expect(redis.keys("gush.jobs.deadbeef.*").length).to eq(1) 341 | end 342 | 343 | it "sets expires_at index when expires_at is provided" do 344 | job = BobJob.new(name: 'bob', id: 'abcd123') 345 | 346 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 347 | expires_at = freeze_time.to_f + 1000 348 | 349 | travel_to(freeze_time) do 350 | client.persist_job('deadbeef', job, expires_at: expires_at) 351 | end 352 | 353 | expect(redis.zrange("gush.idx.jobs.expires_at", 0, -1, with_scores: true)) 354 | .to eq([["deadbeef.#{job.klass}", expires_at]]) 355 | 356 | # Persisting the workflow again should not affect its expires_at index score 357 | client.persist_job('deadbeef', job) 358 | expect(redis.zrange("gush.idx.jobs.expires_at", 0, -1, with_scores: true)) 359 | .to eq([["deadbeef.#{job.klass}", expires_at]]) 360 | end 361 | 362 | it "does not set expires_at index when there is no ttl configured" do 363 | job = BobJob.new(name: 'bob', id: 'abcd123') 364 | client.persist_job('deadbeef', job) 365 | 366 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(0) 367 | end 368 | end 369 | 370 | describe "#workflow_ids" do 371 | it "returns a page of registered workflow ids" do 372 | workflow = TestWorkflow.create 373 | ids = client.workflow_ids 374 | expect(ids).to eq([workflow.id]) 375 | end 376 | 377 | it "sorts workflow ids by created time or reverse created time" do 378 | ids = 3.times.map { TestWorkflow.create }.map(&:id) 379 | 380 | expect(client.workflow_ids).to eq(ids) 381 | expect(client.workflow_ids(order: :asc)).to eq(ids) 382 | expect(client.workflow_ids(order: :desc)).to eq(ids.reverse) 383 | end 384 | 385 | it "supports start and stop params" do 386 | ids = 3.times.map { TestWorkflow.create }.map(&:id) 387 | 388 | expect(client.workflow_ids(0, 1)).to eq(ids.slice(0..1)) 389 | expect(client.workflow_ids(1, 1)).to eq(ids.slice(1..1)) 390 | expect(client.workflow_ids(1, 10)).to eq(ids.slice(1..2)) 391 | expect(client.workflow_ids(0, -1)).to eq(ids) 392 | end 393 | 394 | it "supports start and stop params using created timestamps" do 395 | times = [100, 200, 300] 396 | ids = [] 397 | 398 | times.each do |t| 399 | travel_to Time.at(t) do 400 | ids << TestWorkflow.create.id 401 | end 402 | end 403 | 404 | expect(client.workflow_ids(0, 1, by_ts: true)).to be_empty 405 | expect(client.workflow_ids(50, 150, by_ts: true)).to eq(ids.slice(0..0)) 406 | expect(client.workflow_ids(150, 50, by_ts: true, order: :desc)).to eq(ids.slice(0..0)) 407 | expect(client.workflow_ids("-inf", "inf", by_ts: true)).to eq(ids) 408 | end 409 | end 410 | 411 | describe "#workflows" do 412 | it "returns a page of registered workflows" do 413 | workflow = TestWorkflow.create 414 | expect(client.workflows.map(&:id)).to eq([workflow.id]) 415 | end 416 | end 417 | 418 | describe "#workflows_count" do 419 | it "returns a count of registered workflows" do 420 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 421 | 422 | expect(client.workflows_count).to eq(0) 423 | 424 | workflow = TestWorkflow.create 425 | expect(client.workflows_count).to eq(1) 426 | 427 | client.expire_workflows(Time.now.to_f + 1001) 428 | expect(client.workflows_count).to eq(0) 429 | end 430 | end 431 | 432 | describe "#all_workflows" do 433 | it "returns all registered workflows" do 434 | workflow = TestWorkflow.create 435 | workflows = client.all_workflows 436 | expect(workflows.map(&:id)).to eq([workflow.id]) 437 | end 438 | end 439 | 440 | it "should be able to handle outdated data format" do 441 | workflow = TestWorkflow.create 442 | 443 | # malform the data 444 | hash = Gush::JSON.decode(redis.get("gush.workflows.#{workflow.id}"), symbolize_keys: true) 445 | hash.delete(:stopped) 446 | redis.set("gush.workflows.#{workflow.id}", Gush::JSON.encode(hash)) 447 | 448 | expect { 449 | workflow = client.find_workflow(workflow.id) 450 | expect(workflow.stopped?).to be false 451 | }.not_to raise_error 452 | end 453 | end 454 | -------------------------------------------------------------------------------- /spec/gush/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Configuration do 4 | 5 | it "has defaults set" do 6 | subject.gushfile = GUSHFILE 7 | expect(subject.redis_url).to eq("redis://localhost:6379") 8 | expect(subject.concurrency).to eq(5) 9 | expect(subject.namespace).to eq('gush') 10 | expect(subject.gushfile).to eq(GUSHFILE.realpath) 11 | expect(subject.locking_duration).to eq(2) 12 | expect(subject.polling_interval).to eq(0.3) 13 | end 14 | 15 | describe "#configure" do 16 | it "allows setting options through a block" do 17 | Gush.configure do |config| 18 | config.redis_url = "redis://localhost" 19 | config.concurrency = 25 20 | config.locking_duration = 5 21 | config.polling_interval = 0.5 22 | end 23 | 24 | expect(Gush.configuration.redis_url).to eq("redis://localhost") 25 | expect(Gush.configuration.concurrency).to eq(25) 26 | expect(Gush.configuration.locking_duration).to eq(5) 27 | expect(Gush.configuration.polling_interval).to eq(0.5) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/gush/graph_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Graph do 4 | subject { described_class.new(TestWorkflow.create) } 5 | let(:filename) { "graph.png" } 6 | 7 | describe "#viz" do 8 | it "runs GraphViz to render graph" do 9 | node = double("node", :[]= => true) 10 | edge = double("edge", :[]= => true) 11 | graph = double("graph", node: node, edge: edge) 12 | path = Pathname.new(Dir.tmpdir).join(filename) 13 | 14 | expect(Graphviz::Graph).to receive(:new).and_return(graph) 15 | 16 | node_start = double('start') 17 | node_end = double('end') 18 | node_prepare = double('Prepare') 19 | node_fetch_first_job = double('FetchFirstJob') 20 | node_fetch_second_job = double('FetchSecondJob') 21 | node_normalize_job = double('NormalizeJob') 22 | node_persist_first_job = double('PersistFirstJob') 23 | 24 | expect(graph).to receive(:add_node).with('start', {shape: 'diamond', fillcolor: '#CFF09E', color: "#555555", style: 'filled'}).and_return(node_start) 25 | expect(graph).to receive(:add_node).with('end', {shape: 'diamond', fillcolor: '#F56991', color: "#555555", style: 'filled'}).and_return(node_end) 26 | 27 | standard_options = {:color=>"#555555", :fillcolor=>"white", :label=>"Prepare", :shape=>"ellipse", :style=>"filled"} 28 | 29 | expect(graph).to receive(:add_node).with(/Prepare/, standard_options.merge(label: "Prepare")).and_return(node_prepare) 30 | expect(graph).to receive(:add_node).with(/FetchFirstJob/, standard_options.merge(label: "FetchFirstJob")).and_return(node_fetch_first_job) 31 | expect(graph).to receive(:add_node).with(/FetchSecondJob/, standard_options.merge(label: "FetchSecondJob")).and_return(node_fetch_second_job) 32 | expect(graph).to receive(:add_node).with(/NormalizeJob/, standard_options.merge(label: "NormalizeJob")).and_return(node_normalize_job) 33 | expect(graph).to receive(:add_node).with(/PersistFirstJob/, standard_options.merge(label: "PersistFirstJob")).and_return(node_persist_first_job) 34 | 35 | edge_options = { 36 | dir: "forward", 37 | penwidth: 1, 38 | color: "#555555" 39 | } 40 | 41 | expect(node_start).to receive(:connect).with(node_prepare, **edge_options) 42 | expect(node_prepare).to receive(:connect).with(node_fetch_first_job, **edge_options) 43 | expect(node_prepare).to receive(:connect).with(node_fetch_second_job, **edge_options) 44 | expect(node_fetch_first_job).to receive(:connect).with(node_persist_first_job, **edge_options) 45 | expect(node_fetch_second_job).to receive(:connect).with(node_normalize_job, **edge_options) 46 | expect(node_persist_first_job).to receive(:connect).with(node_normalize_job, **edge_options) 47 | expect(node_normalize_job).to receive(:connect).with(node_end, **edge_options) 48 | 49 | expect(graph).to receive(:dump_graph).and_return(nil) 50 | 51 | subject.viz 52 | end 53 | end 54 | 55 | describe "#path" do 56 | it "returns string path to the rendered graph" do 57 | expect(subject.path).to eq(Pathname.new(Dir.tmpdir).join(filename).to_s) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/gush/job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Job do 4 | 5 | describe "#output" do 6 | it "saves output to output_payload" do 7 | job = described_class.new(name: "a-job") 8 | job.output "something" 9 | expect(job.output_payload).to eq("something") 10 | end 11 | end 12 | describe "#fail!" do 13 | it "sets finished and failed to true and records time" do 14 | job = described_class.new(name: "a-job") 15 | job.fail! 16 | expect(job.failed_at).to eq(Time.now.to_i) 17 | expect(job.failed?).to eq(true) 18 | expect(job.finished?).to eq(true) 19 | expect(job.running?).to eq(false) 20 | expect(job.enqueued?).to eq(false) 21 | end 22 | end 23 | 24 | describe "#finish!" do 25 | it "sets finished to false and failed to false and records time" do 26 | job = described_class.new(name: "a-job") 27 | job.finish! 28 | expect(job.finished_at).to eq(Time.now.to_i) 29 | expect(job.failed?).to eq(false) 30 | expect(job.running?).to eq(false) 31 | expect(job.finished?).to eq(true) 32 | expect(job.enqueued?).to eq(false) 33 | end 34 | end 35 | 36 | describe "#enqueue!" do 37 | it "resets flags to false and sets enqueued to true" do 38 | job = described_class.new(name: "a-job") 39 | job.finished_at = 123 40 | job.failed_at = 123 41 | job.enqueue! 42 | expect(job.started_at).to eq(nil) 43 | expect(job.finished_at).to eq(nil) 44 | expect(job.failed_at).to eq(nil) 45 | expect(job.failed?).to eq(false) 46 | expect(job.finished?).to eq(false) 47 | expect(job.enqueued?).to eq(true) 48 | expect(job.running?).to eq(false) 49 | end 50 | end 51 | 52 | describe "#enqueue_worker!" do 53 | it "enqueues the job using Gush::Worker" do 54 | job = described_class.new(name: "a-job", workflow_id: 123) 55 | 56 | expect { 57 | job.enqueue_worker! 58 | }.to change{ActiveJob::Base.queue_adapter.enqueued_jobs.size}.from(0).to(1) 59 | end 60 | 61 | it "handles ActiveJob.set options" do 62 | freeze_time = Time.utc(2023, 01, 21, 14, 36, 0) 63 | 64 | travel_to freeze_time do 65 | job = described_class.new(name: "a-job", workflow_id: 123) 66 | job.enqueue_worker!(wait_until: freeze_time + 5.minutes) 67 | expect(Gush::Worker).to have_a_job_enqueued_at(123, job_with_id(job.class.name), 5.minutes) 68 | end 69 | end 70 | end 71 | 72 | describe "#worker_options" do 73 | it "returns a blank options hash by default" do 74 | job = described_class.new 75 | expect(job.worker_options).to eq({}) 76 | end 77 | 78 | it "returns a hash with the queue setting" do 79 | job = described_class.new 80 | job.queue = 'my-queue' 81 | expect(job.worker_options).to eq({ queue: 'my-queue' }) 82 | end 83 | 84 | it "returns a hash with the wait setting" do 85 | job = described_class.new 86 | job.wait = 123 87 | expect(job.worker_options).to eq({ wait: 123 }) 88 | end 89 | end 90 | 91 | describe "#start!" do 92 | it "resets flags and marks as running" do 93 | job = described_class.new(name: "a-job") 94 | 95 | job.enqueue! 96 | job.fail! 97 | 98 | now = Time.now.to_i 99 | expect(job.started_at).to eq(nil) 100 | expect(job.failed_at).to eq(now) 101 | 102 | job.start! 103 | 104 | expect(job.started_at).to eq(Time.now.to_i) 105 | expect(job.failed_at).to eq(nil) 106 | end 107 | end 108 | 109 | describe "#as_json" do 110 | context "finished and enqueued set to true" do 111 | it "returns correct hash" do 112 | job = described_class.new( 113 | workflow_id: 123, 114 | id: '702bced5-bb72-4bba-8f6f-15a3afa358bd', 115 | finished_at: 123, 116 | enqueued_at: 120, 117 | wait: 300 118 | ) 119 | expected = { 120 | id: '702bced5-bb72-4bba-8f6f-15a3afa358bd', 121 | klass: "Gush::Job", 122 | incoming: [], 123 | outgoing: [], 124 | failed_at: nil, 125 | started_at: nil, 126 | finished_at: 123, 127 | enqueued_at: 120, 128 | params: {}, 129 | queue: nil, 130 | output_payload: nil, 131 | workflow_id: 123, 132 | wait: 300 133 | } 134 | expect(job.as_json).to eq(expected) 135 | end 136 | end 137 | end 138 | 139 | describe ".from_hash" do 140 | it "properly restores state of the job from hash" do 141 | job = described_class.from_hash( 142 | { 143 | klass: 'Gush::Job', 144 | id: '702bced5-bb72-4bba-8f6f-15a3afa358bd', 145 | incoming: ['a', 'b'], 146 | outgoing: ['c'], 147 | failed_at: 123, 148 | finished_at: 122, 149 | started_at: 55, 150 | enqueued_at: 444 151 | } 152 | ) 153 | 154 | expect(job.id).to eq('702bced5-bb72-4bba-8f6f-15a3afa358bd') 155 | expect(job.name).to eq('Gush::Job|702bced5-bb72-4bba-8f6f-15a3afa358bd') 156 | expect(job.class).to eq(Gush::Job) 157 | expect(job.klass).to eq("Gush::Job") 158 | expect(job.finished?).to eq(true) 159 | expect(job.failed?).to eq(true) 160 | expect(job.enqueued?).to eq(true) 161 | expect(job.incoming).to eq(['a', 'b']) 162 | expect(job.outgoing).to eq(['c']) 163 | expect(job.failed_at).to eq(123) 164 | expect(job.finished_at).to eq(122) 165 | expect(job.started_at).to eq(55) 166 | expect(job.enqueued_at).to eq(444) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/gush/json_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::JSON do 4 | subject { described_class } 5 | 6 | describe ".encode" do 7 | it "encodes data to JSON" do 8 | expect(subject.encode({a: 123})).to eq("{\"a\":123}") 9 | end 10 | end 11 | 12 | describe ".decode" do 13 | it "decodes JSON to data" do 14 | expect(subject.decode("{\"a\":123}")).to eq({"a" => 123}) 15 | end 16 | 17 | it "passes options to the internal parser" do 18 | expect(subject.decode("{\"a\":123}", symbolize_keys: true)).to eq({a: 123}) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/gush/migrate/1_create_gush_workflows_created_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gush/migrate/1_create_gush_workflows_created' 3 | 4 | describe Gush::IndexWorkflowsByCreatedAtAndExpiresAt do 5 | 6 | describe "#up" do 7 | it "adds existing workflows to created_at index, but not expires_at index" do 8 | TestWorkflow.create 9 | redis.del("gush.idx.workflows.created_at") 10 | 11 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 12 | 13 | subject.migrate 14 | 15 | expect(redis.zcard("gush.idx.workflows.created_at")).to eq(1) 16 | expect(redis.zcard("gush.idx.workflows.expires_at")).to eq(0) 17 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(0) 18 | end 19 | 20 | it "adds expiring workflows to expires_at index" do 21 | workflow = TestWorkflow.create 22 | redis.del("gush.idx.workflows.created_at") 23 | 24 | freeze_time = Time.now.round # travel_to doesn't support fractions of a second 25 | travel_to(freeze_time) do 26 | redis.expire("gush.workflows.#{workflow.id}", 1234) 27 | expires_at = freeze_time.to_f + 1234 28 | 29 | allow_any_instance_of(Gush::Configuration).to receive(:ttl).and_return(1000) 30 | 31 | subject.migrate 32 | 33 | expect(redis.ttl("gush.workflows.#{workflow.id}")).to eq(-1) 34 | expect(redis.ttl("gush.jobs.#{workflow.id}.#{workflow.jobs.first.class.name}")).to eq(-1) 35 | 36 | expect(redis.zrange("gush.idx.workflows.expires_at", 0, -1, with_scores: true)) 37 | .to eq([[workflow.id, expires_at]]) 38 | expect(redis.zcard("gush.idx.jobs.expires_at")).to eq(5) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/gush/migration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Migration do 4 | 5 | describe "#migrate" do 6 | it "applies a migration once" do 7 | class TestMigration < Gush::Migration 8 | def self.version 9 | 123 10 | end 11 | end 12 | 13 | migration = TestMigration.new 14 | expect(migration).to receive(:up).once 15 | 16 | expect(migration.migrated?).to be(false) 17 | migration.migrate 18 | 19 | expect(migration.migrated?).to be(true) 20 | migration.migrate 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/gush/worker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Worker do 4 | subject { described_class.new } 5 | 6 | let!(:workflow) { TestWorkflow.create } 7 | let(:locking_duration) { 5 } 8 | let(:polling_interval) { 0.5 } 9 | let!(:job) { client.find_job(workflow.id, "Prepare") } 10 | let(:config) { Gush.configuration.to_json } 11 | let!(:client) { Gush::Client.new } 12 | 13 | describe "#perform" do 14 | context "when job fails" do 15 | it "should mark it as failed" do 16 | class FailingJob < Gush::Job 17 | def perform 18 | invalid.code_to_raise.error 19 | end 20 | end 21 | 22 | class FailingWorkflow < Gush::Workflow 23 | def configure 24 | run FailingJob 25 | end 26 | end 27 | 28 | workflow = FailingWorkflow.create 29 | expect do 30 | subject.perform(workflow.id, "FailingJob") 31 | end.to raise_error(NameError) 32 | expect(client.find_job(workflow.id, "FailingJob")).to be_failed 33 | end 34 | end 35 | 36 | context "when job completes successfully" do 37 | it "should mark it as succedeed" do 38 | expect(subject).to receive(:mark_as_finished) 39 | 40 | subject.perform(workflow.id, "Prepare") 41 | end 42 | end 43 | 44 | context 'when job failed to enqueue outgoing jobs' do 45 | it 'enqeues another job to handling enqueue_outgoing_jobs' do 46 | allow(RedisMutex).to receive(:with_lock).and_raise(RedisMutex::LockError) 47 | subject.perform(workflow.id, 'Prepare') 48 | expect(Gush::Worker).to have_no_jobs(workflow.id, jobs_with_id(["FetchFirstJob", "FetchSecondJob"])) 49 | 50 | allow(RedisMutex).to receive(:with_lock).and_call_original 51 | perform_one 52 | expect(Gush::Worker).to have_jobs(workflow.id, jobs_with_id(["FetchFirstJob", "FetchSecondJob"])) 53 | end 54 | end 55 | 56 | it "calls job.perform method" do 57 | SPY = double() 58 | expect(SPY).to receive(:some_method) 59 | 60 | class OkayJob < Gush::Job 61 | def perform 62 | SPY.some_method 63 | end 64 | end 65 | 66 | class OkayWorkflow < Gush::Workflow 67 | def configure 68 | run OkayJob 69 | end 70 | end 71 | 72 | workflow = OkayWorkflow.create 73 | 74 | subject.perform(workflow.id, 'OkayJob') 75 | end 76 | 77 | it 'calls RedisMutex.with_lock with customizable locking_duration and polling_interval' do 78 | expect(RedisMutex).to receive(:with_lock) 79 | .with(anything, block: 5, sleep: 0.5).twice 80 | subject.perform(workflow.id, 'Prepare') 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/gush/workflow_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush::Workflow do 4 | subject { TestWorkflow.create } 5 | 6 | describe "#initialize" do 7 | it "passes constructor arguments to the method" do 8 | klass = Class.new(Gush::Workflow) do 9 | def configure(*args) 10 | run FetchFirstJob 11 | run PersistFirstJob, after: FetchFirstJob 12 | end 13 | end 14 | 15 | expect_any_instance_of(klass).to receive(:configure).with("arg1", "arg2") 16 | klass.new("arg1", "arg2") 17 | end 18 | 19 | it "passes constructor keyword arguments to the method" do 20 | klass = Class.new(Gush::Workflow) do 21 | def configure(*args, **kwargs) 22 | run FetchFirstJob 23 | run PersistFirstJob, after: FetchFirstJob 24 | end 25 | end 26 | 27 | expect_any_instance_of(klass).to receive(:configure).with("arg1", "arg2", arg3: 123) 28 | klass.new("arg1", "arg2", arg3: 123) 29 | end 30 | 31 | it "accepts globals" do 32 | flow = TestWorkflow.new(globals: { global1: 'foo' }) 33 | expect(flow.globals[:global1]).to eq('foo') 34 | end 35 | 36 | it "accepts internal_state" do 37 | flow = TestWorkflow.new 38 | 39 | internal_state = { 40 | id: flow.id, 41 | jobs: flow.jobs, 42 | dependencies: flow.dependencies, 43 | persisted: true, 44 | stopped: true 45 | } 46 | 47 | flow_copy = TestWorkflow.new(internal_state: internal_state) 48 | 49 | expect(flow_copy.id).to eq(flow.id) 50 | expect(flow_copy.jobs).to eq(flow.jobs) 51 | expect(flow_copy.dependencies).to eq(flow.dependencies) 52 | expect(flow_copy.persisted).to eq(true) 53 | expect(flow_copy.stopped).to eq(true) 54 | end 55 | 56 | it "does not call #configure if needs_setup is false" do 57 | INTERNAL_SETUP_SPY = double('configure spy') 58 | klass = Class.new(Gush::Workflow) do 59 | def configure(*args) 60 | INTERNAL_SETUP_SPY.some_method 61 | end 62 | end 63 | 64 | expect(INTERNAL_SETUP_SPY).not_to receive(:some_method) 65 | 66 | flow = TestWorkflow.new(internal_state: { needs_setup: false }) 67 | end 68 | end 69 | 70 | describe "#find" do 71 | it "fiends a workflow by id" do 72 | expect(Gush::Workflow.find(subject.id).id).to eq(subject.id) 73 | end 74 | end 75 | 76 | describe "#page" do 77 | it "returns a page of registered workflows" do 78 | flow = TestWorkflow.create 79 | expect(Gush::Workflow.page.map(&:id)).to eq([flow.id]) 80 | end 81 | end 82 | 83 | describe "#save" do 84 | context "workflow not persisted" do 85 | it "sets persisted to true" do 86 | flow = TestWorkflow.new 87 | flow.save 88 | expect(flow.persisted).to be(true) 89 | end 90 | 91 | it "assigns new unique id" do 92 | flow = TestWorkflow.new 93 | flow.save 94 | expect(flow.id).to_not be_nil 95 | end 96 | end 97 | 98 | context "workflow persisted" do 99 | it "does not assign new id" do 100 | flow = TestWorkflow.new 101 | flow.save 102 | id = flow.id 103 | flow.save 104 | expect(flow.id).to eq(id) 105 | end 106 | end 107 | end 108 | 109 | describe "#continue" do 110 | it "enqueues failed jobs" do 111 | flow = TestWorkflow.create 112 | flow.find_job('Prepare').fail! 113 | 114 | expect(flow.jobs.select(&:failed?)).not_to be_empty 115 | 116 | flow.continue 117 | 118 | expect(flow.jobs.select(&:failed?)).to be_empty 119 | expect(flow.find_job('Prepare').failed_at).to be_nil 120 | end 121 | end 122 | 123 | describe "#mark_as_stopped" do 124 | it "marks workflow as stopped" do 125 | expect{ subject.mark_as_stopped }.to change{subject.stopped?}.from(false).to(true) 126 | end 127 | end 128 | 129 | describe "#mark_as_started" do 130 | it "removes stopped flag" do 131 | subject.stopped = true 132 | expect{ subject.mark_as_started }.to change{subject.stopped?}.from(true).to(false) 133 | end 134 | end 135 | 136 | describe "#status" do 137 | context "when failed" do 138 | it "returns :failed" do 139 | flow = TestWorkflow.create 140 | flow.find_job("Prepare").fail! 141 | flow.persist! 142 | expect(flow.reload.status).to eq(:failed) 143 | end 144 | end 145 | 146 | it "returns failed" do 147 | subject.find_job('Prepare').fail! 148 | expect(subject.status).to eq(:failed) 149 | end 150 | 151 | it "returns running" do 152 | subject.find_job('Prepare').start! 153 | expect(subject.status).to eq(:running) 154 | end 155 | 156 | it "returns finished" do 157 | subject.jobs.each {|n| n.finish! } 158 | expect(subject.status).to eq(:finished) 159 | end 160 | 161 | it "returns stopped" do 162 | subject.stopped = true 163 | expect(subject.status).to eq(:stopped) 164 | end 165 | 166 | it "returns pending" do 167 | expect(subject.status).to eq(:pending) 168 | end 169 | end 170 | 171 | describe "#to_json" do 172 | it "returns correct hash" do 173 | klass = Class.new(Gush::Workflow) do 174 | def configure(*args) 175 | run FetchFirstJob 176 | run PersistFirstJob, after: FetchFirstJob 177 | end 178 | end 179 | 180 | result = JSON.parse(klass.create("arg1", "arg2", arg3: 123).to_json) 181 | expected = { 182 | "id" => an_instance_of(String), 183 | "name" => klass.to_s, 184 | "klass" => klass.to_s, 185 | "job_klasses" => ["FetchFirstJob", "PersistFirstJob"], 186 | "status" => "pending", 187 | "total" => 2, 188 | "finished" => 0, 189 | "started_at" => nil, 190 | "finished_at" => nil, 191 | "stopped" => false, 192 | "dependencies" => [{ 193 | "from" => "FetchFirstJob", 194 | "to" => job_with_id("PersistFirstJob") 195 | }], 196 | "arguments" => ["arg1", "arg2"], 197 | "kwargs" => {"arg3" => 123}, 198 | "globals" => {} 199 | } 200 | expect(result).to match(expected) 201 | end 202 | end 203 | 204 | describe "#find_job" do 205 | it "finds job by its name" do 206 | expect(TestWorkflow.create.find_job("PersistFirstJob")).to be_instance_of(PersistFirstJob) 207 | end 208 | end 209 | 210 | describe "#run" do 211 | it "allows passing additional params to the job" do 212 | flow = Gush::Workflow.new 213 | flow.run(Gush::Job, params: { something: 1 }) 214 | flow.save 215 | expect(flow.jobs.first.params).to eq({ something: 1 }) 216 | end 217 | 218 | it "merges globals with params and passes them to the job, with job param taking precedence" do 219 | flow = Gush::Workflow.new(globals: { something: 2, global1: 123 }) 220 | flow.run(Gush::Job, params: { something: 1 }) 221 | flow.save 222 | expect(flow.jobs.first.params).to eq({ something: 1, global1: 123 }) 223 | end 224 | 225 | it "allows passing wait param to the job" do 226 | flow = Gush::Workflow.new 227 | flow.run(Gush::Job, wait: 5.seconds) 228 | flow.save 229 | expect(flow.jobs.first.wait).to eq(5.seconds) 230 | end 231 | 232 | context "when graph is empty" do 233 | it "adds new job with the given class as a node" do 234 | flow = Gush::Workflow.new 235 | flow.run(Gush::Job) 236 | flow.save 237 | expect(flow.jobs.first).to be_instance_of(Gush::Job) 238 | end 239 | end 240 | 241 | it "allows `after` to accept an array of jobs" do 242 | tree = Gush::Workflow.new 243 | klass1 = Class.new(Gush::Job) 244 | klass2 = Class.new(Gush::Job) 245 | klass3 = Class.new(Gush::Job) 246 | 247 | tree.run(klass1) 248 | tree.run(klass2, after: [klass1, klass3]) 249 | tree.run(klass3) 250 | 251 | tree.resolve_dependencies 252 | 253 | expect(tree.jobs.first.outgoing).to match_array(jobs_with_id([klass2.to_s])) 254 | end 255 | 256 | it "allows `before` to accept an array of jobs" do 257 | tree = Gush::Workflow.new 258 | klass1 = Class.new(Gush::Job) 259 | klass2 = Class.new(Gush::Job) 260 | klass3 = Class.new(Gush::Job) 261 | tree.run(klass1) 262 | tree.run(klass2, before: [klass1, klass3]) 263 | tree.run(klass3) 264 | 265 | tree.resolve_dependencies 266 | 267 | expect(tree.jobs.first.incoming).to match_array(jobs_with_id([klass2.to_s])) 268 | end 269 | 270 | it "attaches job as a child of the job in `after` key" do 271 | tree = Gush::Workflow.new 272 | klass1 = Class.new(Gush::Job) 273 | klass2 = Class.new(Gush::Job) 274 | tree.run(klass1) 275 | tree.run(klass2, after: klass1) 276 | tree.resolve_dependencies 277 | job = tree.jobs.first 278 | expect(job.outgoing).to match_array(jobs_with_id([klass2.to_s])) 279 | end 280 | 281 | it "attaches job as a parent of the job in `before` key" do 282 | tree = Gush::Workflow.new 283 | klass1 = Class.new(Gush::Job) 284 | klass2 = Class.new(Gush::Job) 285 | tree.run(klass1) 286 | tree.run(klass2, before: klass1) 287 | tree.resolve_dependencies 288 | job = tree.jobs.first 289 | expect(job.incoming).to match_array(jobs_with_id([klass2.to_s])) 290 | end 291 | end 292 | 293 | describe "#failed?" do 294 | context "when one of the jobs failed" do 295 | it "returns true" do 296 | subject.find_job('Prepare').fail! 297 | expect(subject.failed?).to be_truthy 298 | end 299 | end 300 | 301 | context "when no jobs failed" do 302 | it "returns true" do 303 | expect(subject.failed?).to be_falsy 304 | end 305 | end 306 | end 307 | 308 | describe "#running?" do 309 | context "when no enqueued or running jobs" do 310 | it "returns false" do 311 | expect(subject.running?).to be_falsy 312 | end 313 | end 314 | 315 | context "when some jobs are running" do 316 | it "returns true" do 317 | subject.find_job('Prepare').start! 318 | expect(subject.running?).to be_truthy 319 | end 320 | end 321 | end 322 | 323 | describe "#finished?" do 324 | it "returns false if any jobs are unfinished" do 325 | expect(subject.finished?).to be_falsy 326 | end 327 | 328 | it "returns true if all jobs are finished" do 329 | subject.jobs.each {|n| n.finish! } 330 | expect(subject.finished?).to be_truthy 331 | end 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /spec/gush_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Gush do 4 | describe ".gushfile" do 5 | let(:path) { Pathname("/tmp/Gushfile.rb") } 6 | 7 | context "Gushfile is missing from pwd" do 8 | it "returns nil" do 9 | path.delete if path.exist? 10 | Gush.configuration.gushfile = path 11 | 12 | expect(Gush.gushfile).to eq(nil) 13 | end 14 | end 15 | 16 | context "Gushfile exists" do 17 | it "returns Pathname to it" do 18 | FileUtils.touch(path) 19 | Gush.configuration.gushfile = path 20 | expect(Gush.gushfile).to eq(path.realpath) 21 | path.delete 22 | end 23 | end 24 | end 25 | 26 | describe ".root" do 27 | it "returns root directory of Gush" do 28 | expected = Pathname.new(__FILE__).parent.parent 29 | expect(Gush.root).to eq(expected) 30 | end 31 | end 32 | 33 | describe ".configure" do 34 | it "runs block with config instance passed" do 35 | expect { |b| Gush.configure(&b) }.to yield_with_args(Gush.configuration) 36 | end 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/testing/time_helpers' 3 | require 'gush' 4 | require 'json' 5 | require 'pry' 6 | 7 | ActiveJob::Base.queue_adapter = :test 8 | ActiveJob::Base.logger = nil 9 | 10 | class Prepare < Gush::Job; end 11 | class FetchFirstJob < Gush::Job; end 12 | class FetchSecondJob < Gush::Job; end 13 | class PersistFirstJob < Gush::Job; end 14 | class PersistSecondJob < Gush::Job; end 15 | class NormalizeJob < Gush::Job; end 16 | class BobJob < Gush::Job; end 17 | 18 | GUSHFILE = Pathname.new(__FILE__).parent.join("Gushfile") 19 | 20 | class TestWorkflow < Gush::Workflow 21 | def configure 22 | run Prepare 23 | 24 | run NormalizeJob 25 | 26 | run FetchFirstJob, after: Prepare 27 | run FetchSecondJob, after: Prepare, before: NormalizeJob 28 | 29 | run PersistFirstJob, after: FetchFirstJob, before: NormalizeJob 30 | end 31 | end 32 | 33 | class ParameterTestWorkflow < Gush::Workflow 34 | def configure(param, kwarg: false) 35 | run Prepare if param || kwarg 36 | end 37 | end 38 | 39 | class WaitableTestWorkflow < Gush::Workflow 40 | def configure 41 | run Prepare, wait: 5.minutes 42 | end 43 | end 44 | 45 | REDIS_URL = ENV["REDIS_URL"] || "redis://localhost:6379/12" 46 | 47 | module GushHelpers 48 | def redis 49 | @redis ||= Redis.new(url: REDIS_URL) 50 | end 51 | 52 | def perform_one 53 | job = ActiveJob::Base.queue_adapter.enqueued_jobs.first 54 | if job 55 | Gush::Worker.new.perform(*job[:args]) 56 | ActiveJob::Base.queue_adapter.performed_jobs << job 57 | ActiveJob::Base.queue_adapter.enqueued_jobs.shift 58 | end 59 | end 60 | 61 | def jobs_with_id(jobs_array) 62 | jobs_array.map {|job_name| job_with_id(job_name) } 63 | end 64 | 65 | def job_with_id(job_name) 66 | /#{job_name}|(?.*)/ 67 | end 68 | end 69 | 70 | RSpec::Matchers.define :have_jobs do |flow, jobs| 71 | match do |actual| 72 | expected = jobs.map do |job| 73 | hash_including(args: include(flow, job)) 74 | end 75 | expect(ActiveJob::Base.queue_adapter.enqueued_jobs).to match_array(expected) 76 | end 77 | 78 | failure_message do |actual| 79 | "expected queue to have #{jobs}, but instead has: #{ActiveJob::Base.queue_adapter.enqueued_jobs.map{ |j| j[:args][1]}}" 80 | end 81 | end 82 | 83 | RSpec::Matchers.define :have_no_jobs do |flow, jobs| 84 | match do |actual| 85 | expected = jobs.map do |job| 86 | hash_including(args: include(flow, job)) 87 | end 88 | expect(ActiveJob::Base.queue_adapter.enqueued_jobs).not_to match_array(expected) 89 | end 90 | 91 | failure_message do |actual| 92 | "expected queue to have no #{jobs}, but instead has: #{ActiveJob::Base.queue_adapter.enqueued_jobs.map{ |j| j[:args][1]}}" 93 | end 94 | end 95 | 96 | RSpec::Matchers.define :have_a_job_enqueued_at do |flow, job, at| 97 | expected_execution_timestamp = (Time.current.utc + at).to_i 98 | 99 | match do |actual| 100 | expected = hash_including(args: include(flow, job), at: expected_execution_timestamp) 101 | 102 | expect(ActiveJob::Base.queue_adapter.enqueued_jobs).to match_array(expected) 103 | end 104 | 105 | failure_message do |actual| 106 | "expected to have enqueued job #{job} to be executed at #{Time.current.utc + at}, but instead has: #{Time.at(enqueued_jobs.first[:at]).to_datetime.utc}" 107 | end 108 | end 109 | 110 | RSpec.configure do |config| 111 | config.include ActiveSupport::Testing::TimeHelpers 112 | config.include ActiveJob::TestHelper 113 | config.include GushHelpers 114 | 115 | config.mock_with :rspec do |mocks| 116 | mocks.verify_partial_doubles = true 117 | end 118 | 119 | config.before(:each) do 120 | clear_enqueued_jobs 121 | clear_performed_jobs 122 | 123 | Gush.configure do |c| 124 | c.redis_url = REDIS_URL 125 | c.gushfile = GUSHFILE 126 | c.locking_duration = defined?(locking_duration) ? locking_duration : 2 127 | c.polling_interval = defined?(polling_interval) ? polling_interval : 0.3 128 | end 129 | end 130 | 131 | config.after(:each) do 132 | clear_enqueued_jobs 133 | clear_performed_jobs 134 | redis.flushdb 135 | end 136 | end 137 | --------------------------------------------------------------------------------