{"id":13878104,"url":"https://github.com/jjb/ruby-clock","last_synced_at":"2025-07-16T14:31:26.182Z","repository":{"id":46987815,"uuid":"325326727","full_name":"jjb/ruby-clock","owner":"jjb","description":"A ruby job scheduler which runs jobs each in their own thread in a persistent process.","archived":false,"fork":false,"pushed_at":"2025-03-10T22:23:52.000Z","size":419,"stargazers_count":85,"open_issues_count":2,"forks_count":1,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-06-17T00:52:27.865Z","etag":null,"topics":["cron","scheduler"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jjb.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-12-29T15:51:15.000Z","updated_at":"2025-06-14T03:00:55.000Z","dependencies_parsed_at":"2023-02-15T10:30:35.521Z","dependency_job_id":"b0be72a8-5882-43ee-a3cb-82fa1aba44c0","html_url":"https://github.com/jjb/ruby-clock","commit_stats":null,"previous_names":[],"tags_count":22,"template":false,"template_full_name":null,"purl":"pkg:github/jjb/ruby-clock","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jjb%2Fruby-clock","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jjb%2Fruby-clock/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jjb%2Fruby-clock/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jjb%2Fruby-clock/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jjb","download_url":"https://codeload.github.com/jjb/ruby-clock/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jjb%2Fruby-clock/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265518475,"owners_count":23780967,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cron","scheduler"],"created_at":"2024-08-06T08:01:39.919Z","updated_at":"2025-07-16T14:31:25.828Z","avatar_url":"https://github.com/jjb.png","language":"Ruby","readme":"# ruby-clock\n\nruby-clock is a [job scheduler](https://en.wikipedia.org/wiki/Job_scheduler),\nknown by heroku as a [clock process](https://devcenter.heroku.com/articles/scheduled-jobs-custom-clock-processes).\nIn many cases it can replace the use of cron.\n\nWhy another ruby scheduler project? See\n[this feature matrix of the space](https://docs.google.com/spreadsheets/d/148VMKY9iyOyUASYytSGiUJKvH0-O5Ri-3Cwr3S6DRPU/edit?usp=sharing).\nFeel free to leave a comment with suggestions for changes or additions.\n\nThis gem is very small with very few lines of code. For all its scheduling capabilities,\nit relies on the venerable [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler/).\nrufus-scheduler\n[does not aim to be a standalone process or a cron replacement](https://github.com/jmettraux/rufus-scheduler/issues/307),\nruby-clock does.\n\nJobs are all run in their own parallel threads within the same process.\n\nThe clock process will respond to signals `INT` (\u003ckbd\u003e^c\u003c/kbd\u003e at the command line) and\n`TERM` (signal sent by environments such as Heroku and other PaaS's when shutting down).\nIn both cases, the clock will stop running jobs and give existing jobs 29 seconds\nto stop before killing them.\nYou can change this number with `RUBY_CLOCK_SHUTDOWN_WAIT_SECONDS` in the environment.\n\n## Installation\n\nruby \u003e= 3.0 is required.\n\nAdd this to your Gemfile:\n\n```ruby\ngem 'ruby-clock', require: false\n```\n\nAnd then execute:\n\n    $ bundle install\n\nOr install it yourself as:\n\n    $ gem install ruby-clock\n\n## Usage\n\nCreate a file named Clockfile. This will hold your job definitions.\nDefine jobs like this:\n\n```ruby\nusing RubyClock::DSL\n\nevery('5 minutes') do\n  UserDataReports.generate\nend\n\n# do something every day, five minutes after midnight\ncron '5 0 * * *' do\n  DailyActivitySummary.generate_and_send\nend\n```\n\nTo start your clock process:\n\n    bundle exec clock\n\nTo use a file other than Clockfile for job definitions, specify it.\nThis will ignore Clockfile and only read jobs from clocks/MyClockfile:\n\n    bundle exec clock clocks/MyClockfile\n\nYou can also load multiple files with one invocation\n(although a better approach might be to load your subfiles within a top-level Clockfile):\n\n    bundle exec clock clocks/daily.rb clocks/weekly.rb\n\n### Rails\n\nTo run your clock process in your app's environment:\n\n    bundle exec clock\n\nTo get smarter database connection management (such as in the case of a database restart or upgrade,\nand maybe other benefits) and code reloading in dev (app code, not the code in Clockfile itself),\njobs are automatically wrapped in the\n[rails app reloader](https://guides.rubyonrails.org/threading_and_code_execution.html).\n\n### Non-Rails\n\nRequire your app's code at the top of Clockfile:\n\n```ruby\nrequire_relative './lib/app.rb'\nevery('5 minutes') do\n...\n```\n\n### Heroku and other PaaS's\n\nAdd this line to your Procfile\n\n```\nclock: bundle exec clock\n```\n\nYou might have a main clock for general scheduled jobs, and then standalone ones\nif your system has something where you want to monitor and adjust resources\nfor that work more precisely. Here, maybe the main clock needs a 2GB instance,\nand the others each need 1GB all to themselves:\n\n```\nclock: bundle exec clock\nthing_checker: bundle exec clock clocks/thing_checker.rb\nthing_reporter: bundle exec clock clocks/thing_reporter.rb\n```\n\nBecause of this feature, do I regret using \"Clockfile\" instead of, say, \"clock.rb\"? Maybe.\n\n### Observing logs\n\nBecause STDOUT does not flush until a certain amount of data has gone into it,\nyou might not immediately see the ruby-clock startup message or job output if\nviewing logs in a deployed environment such as Heroku where the logs are redirected\nto another process or file. To change this behavior and have logs flush immediately,\nadd `$stdout.sync = true` to the top of your Clockfile.\n\n\n\n### Testing\n\nYou can use the `--environment-and-syntax-check` flag to load the app environment and check\nClockfile syntax without actually running jobs. This can be used to check if cron syntax\nis valid during dev, or in automate tests.\n\n```ruby\n# system returns true/false depending on 0/1 exit status of process\nassert(system(\"bundle exec clock --environment-and-syntax-check clock/my_clockfile.rb\"))\n```\n\nYou can use `--check-slug-uniqueness` to check if all the auto-generated slugs are unique. If you have\nmultiple files with jobs, you need to pass them all in with one invocation in order to check global uniqueness.\n\n```ruby\n# system returns true/false depending on 0/1 exit status of process\nassert(system(\"bundle exec clock --check-slug-uniqueness\")) # loads Clockfile\nassert(system(\"bundle exec clock --check-slug-uniqueness clock/weekly.rb clock/daily.rb\")) # load specific files\n```\n\n### Visualization with cronv\n\nUsing the `--generate-dummy-crontab` flag you can visualize your schedule with [cronv](https://github.com/takumakanari/cronv).\nFor your jobs with cron-style schedules, it will generate a dummy crontab file that can be ingested by cronv.\nFor your jobs with \"Every X seconds\" schedules, a comment will be made in the file and they will not be vizualized.\n\n```shell\n## install go\nbrew install go # homebrew\nsudo port install go # macports\n\n## install cronv https://github.com/takumakanari/cronv#go-install\ngo install -v github.com/takumakanari/cronv/cronv@0.4.5\n\n## generate dummy crontab\nbundle exec clock --generate-dummy-crontab Clockfile ../clock/daily.rb ../clock/weekly.rb \u003e dummycron.txt\n## IMPORTANT: open dummycron.txt in an editor and remove the boot startup message cruft from the top\ncat dummycron.txt | ~/go/bin/cronv --duration=1d --title='Clock Jobs' --width=50 -o ./my_cron_schedule.html\nopen my_cron_schedule.html\n```\n\n## Best Practice: use ruby-clock for scheduling, not work\n\nIt's a good idea to do as little work as possible in the clock job. Ideally, your clock jobs\nwill kick off background jobs. This allows the clock process to run with very little resources, even\nfor a Clockfile with hundreds of jobs that run close to one another or at the same time. It also decreases\nthe liklihood that a restart or deploy will cause a job to not run.\n\n```ruby\n# bad\nevery '1 minute' do\n  User.needs_update.find_each{|u| u.update_stats }\nend\n\n# good\nevery '1 minute' do\n  UserStatsUpdaterJob.perform_async\nend\n```\n\nFor this reason, there will probably never be support for using multiple cores. Even for a very complex schedule,\none core and not a lot of ram should suffice.\n\nThat said, it's perfectly fine to do work in ruby-clock. Maybe for a new project, you just have a few scheduled\ntasks, and just want to write out the business logic all in one place and be done with it. There's no risk of lock-in\nwith this approach. You can easily move the work to a background job at any time down the road.\n\n## More Config and Capabilities\n\n### Error Handling\n\nYou can catch and report errors raised in your jobs by defining an error catcher at\nthe top of your Clockfile like this. You should handle these two cases so that you can get\nerror reports about problems while loading the Clockfile:\n\n```ruby\non_error do |job, error|\n  case job\n  when String # this means there was a problem parsing the Clockfile while starting\n    ErrorReporter.track_exception(StandardError.new(error), tag: 'clock', severity: 'high')\n  else\n    ErrorReporter.track_exception(error, tag: 'clock', custom_attribute: {job_name: job.identifier})\n  end\nend\n```\n\n### Callbacks\n\nYou can define around callbacks which will run for all jobs, like shown below.\nThis somewhat awkward syntax is necessary in order to enable the ability to define multiple callbacks.\n(perhaps in different files, shared by multiple Clockfiles, etc.).\n\n```ruby\naround_action do |job_proc, job_info|\n  puts \"before1 #{job_info.class}\"\n  job_proc.call\n  puts \"after1\"\nend\n\naround_action do |job_proc|\n  puts \"before2\"\n  job_proc.call\n  puts \"after2\"\nend\n\nevery('2 seconds') do\n  puts \"hello from a ruby-clock job\"\nend\n```\n\n\n```\nbefore1 Rufus::Scheduler::EveryJob\nbefore2\nhello from a ruby-clock job\nafter2\nafter1\n```\n\nThe around callbacks code will be run in the individual job thread.\n\nrufus-scheduler also provides before and after hooks. ruby-clock does not provide convenience methods for these\nbut you can easily use them via the `schedule` object. These will run in the outer scheduling thread and not in\nthe job thread, so they may have slightly different behavior in some cases. There is likely no reason to use them\ninstead of `around_action`.\nRead [the rufus-scheduler documentation](https://github.com/jmettraux/rufus-scheduler/#callbacks)\nto learn how to do this. Where the documentation references `s`, you should use `schedule`.\n\n## Max Worker Threads\n\nYou can define the maximum number of worker threads that ruby-clock will use.\n\n```ruby\nschedule.max_work_threads = 42\n```\n\n[The default is 28](https://github.com/jmettraux/rufus-scheduler/#max_work_threads).\n\nImpact of this number:\n* **It will determine the max number of database connections\n  used.** The highest number of simultaneous jobs running _which access the database_ will be the highest\n  number of database connections used at any one time. If all of your jobs are not doing work, but only enqueueing background jobs,\n  and you use a redis-backed background job system, then they will use no database connections, so even a high number of\n  simultaneous threads is not an issue. If you use a database-backed background job system, that's a very different story, and\n  you may want to keep this in mind when setting this value.\n* **If threads are not available, jobs will wait to be enqueued.** If jobs are very fast (like if they only enqueue background jobs),\n  then this doesn't matter much, even with a very high number of simultaneous jobs. If 100 jobs which each take an average of 1 second\n  are scheduled at the same time:\n  * with 100 threads they will take about 1 second\n  * with 50 threads they will take about 2 seconds\n  * with 25 threads they will take about 4 seconds\n  * with 12 threads they will take about 8 seconds\n\n### Variables\n\nLike all rufus-scheduler features,\n[local variables](https://github.com/jmettraux/rufus-scheduler/#--key-has_key-keys-values-and-entries)\ncan be defined per job. These can be used\nin various ways, notably accessible by around actions and error handlers.\n\n```ruby\ncron '5 0 * * *', locals: { app_area: 'reports' } do\n  DailyActivitySummary.generate_and_send\nend\n\naround_action do |job_proc, job_info|\n  StatsTracker.increment(\"#{job_info[:app_area]} jobs\")\n  job_proc.call\nend\non_error do |job, error|\n  case job\n  when String # this means there was a problem parsing the Clockfile while starting\n    ErrorReporter.track_exception(error, tag: ['clock', job[:app_area]], severity: 'high')\n  else\n    ErrorReporter.track_exception(error, tag: ['clock', job[:app_area]], custom_attribute: {job_name: job.identifier})\n  end\nend\n```\n\n### Shell commands\n\nYou can run shell commands in your jobs.\n\n```ruby\nevery '1 day' do\n  shell('scripts/process_stuff.sh')\nend\n```\n\nBy default they will be run with\n[ruby backticks](https://livebook.manning.com/concept/ruby/backtick).\nFor better performance, install the [terrapin](https://github.com/thoughtbot/terrapin)\ngem.\n\n`shell` is a convenience method which just passes the string on.\nIf you want to use other terrapin features, you can skip the `shell` command\nand use terrapin directly:\n\n```ruby\nevery '1 day' do\n  line = Terrapin::CommandLine.new('optimize_png', \":file\")\n  Organization.with_new_logos.find_each do |o|\n    line.run(file: o.logo_file_path)\n    o.update!(logo_optimized: true)\n  end\nend\n```\n\n#### shutdown behavior\n\nBecause of [this](https://stackoverflow.com/questions/69653842/),\nif a shell job is running during shutdown, shutdown behavior seems to be changed\nfor _all_ running jobs - they no longer are allowed to finish within the timeout period.\nEverything exits immediately.\n\nUntil this is figured out, if you are concerned about jobs exiting inelegantly,\nyou may want to run your shell jobs in their own separate clock process.\n\n```\nbundle exec clock clocks/main_jobs.rb\nbundle exec clock clocks/shell_jobs.rb\n```\n\n\n### Rake tasks\n\nYou can run tasks from within the persistent runtime of ruby-clock, without\nneeding to shell out and start another process.\n\n```ruby\nevery '1 day' do\n  rake('reports:daily')\nend\n```\n\nThere are also `rake_execute` and `rake_async`.\nSee [the code](https://github.com/jjb/ruby-clock/blob/main/lib/ruby-clock/rake.rb)\nand [this article](https://code.jjb.cc/running-rake-tasks-from-within-ruby-on-rails-code) for more info.\n\n### Job Identifier \u0026 Slug\n\nruby-clock adds the `identifier` method to `Rufus::Scheduler::Job`. This method will return the job's\n[name](https://github.com/jmettraux/rufus-scheduler/#name--string) if one was given.\nIf a name is not given, the last non-comment code line in the job's block\nwill be used instead. If for some reason an error is encountered while calculating this, the next\nfallback is the line number of the job in Clockfile.\n\nThere is also the `slug` method, which produces a slug using\n[ActiveSupport parameterize](https://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize),\nand with underscores changed to hyphens.\nIf the `activesupport` gem is not in your Gemfile and you attempt to use `slug`, it will fail.\n\nSome examples of identifiers and slugs:\n\n```ruby\nevery '1 second', name: 'my job' do\n  Foo.bar\nend\n# my job, my-job\n\nevery '1 day' do\n  daily_things = Foo.setup_daily\n  daily_things.process\n  # TODO: figure out best time of day\nend\n# daily_things.process, daily-things-process\n\n# n.b. ruby-clock isn't yet smart enough to remove trailing comments\nevery '1 week' do\n  weekly_things = Foo.setup_weekly\n  weekly_things.process # does this work???!1~\nend\n# weekly_things.process # does this work???!1~, weekly-things-process-does-this-work-1\n```\n\nThe identifier can be used for keeping track of job behavior in logs or a\nstats tracker. For example:\n\n```ruby\naround_action do |job_proc, job_info|\n  trigger_time = Time.now\n  job_proc.call\n  duration = Time.now-trigger_time.to_t\n  StatsTracker.value('Clock: Job Execution Time', duration.round(2))\n  StatsTracker.value(\"Clock: Job #{job_info.identifier} Execution Time\", duration.round(2))\n  StatsTracker.increment('Clock: Job Executions')\nend\n\nevery '10 seconds', name: 'thread stats' do\n  thread_usage = Hash.new(0)\n  schedule.work_threads(:active).each do |t|\n    thread_usage[t[:rufus_scheduler_job].identifier] += 1\n  end\n  thread_usage.each do |job, count|\n    StatsTracker.value(\"Clock: Job #{job} Active Threads\", count)\n  end\n\n  StatsTracker.value(\"Clock: Active Threads\", schedule.work_threads(:active).size)\n  StatsTracker.value(\"Clock: Vacant Threads\", schedule.work_threads(:vacant).size)\n  StatsTracker.value(\"Clock: DB Pool Size\", ActiveRecord::Base.connection_pool.connections.size)\nend\n```\n\nThe slug can be used for similar purposes where a slug-style string is needed. Here's an example\nreporting a job to a scheduled job monitor, using\n[healthchecks.io](https://blog.healthchecks.io/2023/07/new-feature-check-auto-provisioning/)\nas an example:\n\n```ruby\naround_action do |job_proc, job_info|\n  Net::HTTP.get(\"https://hc-ping.com/#{ENV['HEALTHCHECKS_PING_KEY']}/#{job_info.slug}/start?create=1\")\n  job_proc.call\n  Net::HTTP.get(\"https://hc-ping.com/#{ENV['HEALTHCHECKS_PING_KEY']}/#{job_info.slug}?create=1\")\nend\n```\n\n### Other rufus-scheduler Options\n\nAll [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler/) options are set to defaults.\nThere is a `schedule` variable available in your Clockfile, which is the singleton instance of `Rufus::Scheduler`.\nruby-clock methods such as `every` and `cron` are convenience methods which invoke `schedule.every`\nand `schedule.cron`.\nAnything you can do on this instance, you can do in your Clockfile.\nSee the rufus-scheduler documentation to see what you can do.\n\nIf you have ideas for rufus-scheduler features that can be brought in as\nmore abstract or default ruby-clock behavior, let me know!\n\n## Syntax highlighting for Clockfile\n\nTo tell github and maybe other systems to syntax highlight Clockfile, put this in a .gitattributes file:\n\n```gitattributes\nClockfile linguist-language=Ruby\n```\n\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","funding_links":[],"categories":["Ruby","Scheduling"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjjb%2Fruby-clock","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjjb%2Fruby-clock","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjjb%2Fruby-clock/lists"}