onetimer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +17 -0
- data/.github/workflows/publish.yml +26 -0
- data/README.md +80 -0
- data/Rakefile +12 -0
- data/app/models/onetimer/task.rb +10 -0
- data/justfile +38 -0
- data/lib/generators/onetimer/install/install_generator.rb +36 -0
- data/lib/generators/onetimer/install/templates/create_onetimer_tasks.rb.erb +16 -0
- data/lib/onetimer/engine.rb +18 -0
- data/lib/onetimer/runner.rb +74 -0
- data/lib/onetimer/version.rb +5 -0
- data/lib/onetimer.rb +13 -0
- data/lib/tasks/onetimer.rake +15 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1d4fcc9a49372780a20e6759680cecd890a0120515c50c2120a1161fbe96b448
|
|
4
|
+
data.tar.gz: c2506fb1a9033af810c3413958abe80b35f093690a5fc2feee69bb13b2ea1505
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dc3bbd42e86ed66d44d8596fb590fbea6b610abec00a69dcf382f217e3c4dccd55be269fac89e4d62505ae885436749df5d4545226fe342853583fbb2366d314
|
|
7
|
+
data.tar.gz: 329e21f3b88544e2f397061340e6568d3385719248146f1fa8d1bfe25b2468b47bb3f7ec6d86c99d5242ea909ed03f5981d30ee0a4b5c0a71ef6fb5851583cd5
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: ruby/setup-ruby@v1
|
|
14
|
+
with:
|
|
15
|
+
ruby-version: "3.2"
|
|
16
|
+
bundler-cache: true
|
|
17
|
+
- run: bundle exec rake
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to RubyGems
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: ruby/setup-ruby@v1
|
|
16
|
+
with:
|
|
17
|
+
ruby-version: "3.2"
|
|
18
|
+
bundler-cache: true
|
|
19
|
+
- run: bundle exec rake test rubocop
|
|
20
|
+
- name: Build and push gem
|
|
21
|
+
env:
|
|
22
|
+
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
23
|
+
run: |
|
|
24
|
+
mkdir -p pkg
|
|
25
|
+
gem build onetimer.gemspec -o pkg/onetimer.gem
|
|
26
|
+
gem push pkg/onetimer.gem
|
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Onetimer
|
|
2
|
+
|
|
3
|
+
Runs one-off data tasks exactly once, the way `db:migrate` runs schema
|
|
4
|
+
migrations exactly once — safe to run repeatedly, safe across concurrent
|
|
5
|
+
deploy machines.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "onetimer"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle install
|
|
15
|
+
bin/rails generate onetimer:install
|
|
16
|
+
bin/rails db:migrate
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The generator adds a migration for the `onetimer_tasks` tracking table and
|
|
20
|
+
creates `lib/one_timers/`.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Generate a task:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rake onetimer:new NAME=backfill_something
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This creates `lib/one_timers/<timestamp>_backfill_something.rb`:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
module OneTimers
|
|
34
|
+
class BackfillSomething
|
|
35
|
+
def run
|
|
36
|
+
# One-time task logic goes here.
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run pending tasks (idempotent — already-completed tasks are skipped):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bin/rake onetimer:run
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Typically wired into your deploy entrypoint alongside `db:migrate`.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Tasks live in `lib/one_timers/` by default. Override in an initializer:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Rails.application.config.onetimer.tasks_dir = Rails.root.join("lib/data_tasks")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
just setup
|
|
62
|
+
just test
|
|
63
|
+
just lint
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Releasing
|
|
67
|
+
|
|
68
|
+
1. Bump `Onetimer::VERSION` in `lib/onetimer/version.rb`, commit.
|
|
69
|
+
2. `just release` — tags the commit and pushes the tag.
|
|
70
|
+
3. CI (`.github/workflows/publish.yml`) builds the gem and pushes it to
|
|
71
|
+
RubyGems.org using the `RUBYGEMS_API_KEY` repo secret.
|
|
72
|
+
|
|
73
|
+
One-time setup: add a RubyGems.org API key (Account → API Keys, scoped to
|
|
74
|
+
"Push rubygem") as the `RUBYGEMS_API_KEY` secret in the repo's Actions
|
|
75
|
+
settings.
|
|
76
|
+
|
|
77
|
+
## Contributing
|
|
78
|
+
|
|
79
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
80
|
+
https://github.com/z19r/onetimer.
|
data/Rakefile
ADDED
data/justfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
## Onetimer — development workflows
|
|
2
|
+
|
|
3
|
+
# First-time setup: install dependencies
|
|
4
|
+
setup:
|
|
5
|
+
bundle install
|
|
6
|
+
|
|
7
|
+
# Run the test suite
|
|
8
|
+
test:
|
|
9
|
+
bundle exec rake test
|
|
10
|
+
|
|
11
|
+
# Run RuboCop
|
|
12
|
+
lint:
|
|
13
|
+
bundle exec rubocop
|
|
14
|
+
|
|
15
|
+
# Auto-fix RuboCop offenses where safe
|
|
16
|
+
lint-fix:
|
|
17
|
+
bundle exec rubocop -a
|
|
18
|
+
|
|
19
|
+
# Run tests + lint (mirrors the default rake task)
|
|
20
|
+
build:
|
|
21
|
+
bundle exec rake
|
|
22
|
+
|
|
23
|
+
# Open an interactive console with the gem loaded
|
|
24
|
+
console:
|
|
25
|
+
bin/console
|
|
26
|
+
|
|
27
|
+
# Remove generated test artifacts
|
|
28
|
+
clean:
|
|
29
|
+
rm -rf test/dummy/db/*.sqlite3 test/dummy/log coverage pkg
|
|
30
|
+
|
|
31
|
+
# Tag the version in lib/onetimer/version.rb and push — CI publishes to RubyGems
|
|
32
|
+
release:
|
|
33
|
+
#!/usr/bin/env bash
|
|
34
|
+
set -euo pipefail
|
|
35
|
+
version=$(ruby -Ilib -e 'require "onetimer/version"; puts Onetimer::VERSION')
|
|
36
|
+
git tag "v${version}"
|
|
37
|
+
git push origin "v${version}"
|
|
38
|
+
echo "Pushed v${version} — CI will build and publish to RubyGems"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Onetimer
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"create_onetimer_tasks.rb.erb",
|
|
20
|
+
"db/migrate/create_onetimer_tasks.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_tasks_directory
|
|
25
|
+
empty_directory "lib/one_timers"
|
|
26
|
+
create_file "lib/one_timers/.keep"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def migration_version
|
|
32
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateOnetimerTasks < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :onetimer_tasks do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :status, null: false, default: "running"
|
|
8
|
+
t.datetime :started_at, null: false
|
|
9
|
+
t.datetime :finished_at
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :onetimer_tasks, :name, unique: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetimer
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Onetimer
|
|
6
|
+
|
|
7
|
+
config.onetimer = ActiveSupport::OrderedOptions.new
|
|
8
|
+
config.onetimer.tasks_dir = nil # set per-host-app; defaults to Rails.root/lib/one_timers
|
|
9
|
+
|
|
10
|
+
initializer "onetimer.tasks_dir" do |app|
|
|
11
|
+
Onetimer.tasks_dir = app.config.onetimer.tasks_dir || Rails.root.join("lib/one_timers")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
rake_tasks do
|
|
15
|
+
load File.expand_path("../tasks/onetimer.rake", __dir__)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetimer
|
|
4
|
+
# Runs one-off data tasks exactly once, the way db:migrate runs schema
|
|
5
|
+
# migrations exactly once. Task files live in Onetimer.tasks_dir (default
|
|
6
|
+
# lib/one_timers/), named <timestamp>_<description>.rb, each defining
|
|
7
|
+
# OneTimers::<Description> with a #run method. Generate one with
|
|
8
|
+
# `rake onetimer:new NAME=...`.
|
|
9
|
+
class Runner
|
|
10
|
+
def self.run_pending!
|
|
11
|
+
task_files.each { |file| run_task(file) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.generate!(name)
|
|
15
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
16
|
+
class_name = name.camelize
|
|
17
|
+
path = Onetimer.tasks_dir.join("#{timestamp}_#{name.underscore}.rb")
|
|
18
|
+
|
|
19
|
+
FileUtils.mkdir_p(Onetimer.tasks_dir)
|
|
20
|
+
File.write(path, <<~RUBY)
|
|
21
|
+
# frozen_string_literal: true
|
|
22
|
+
|
|
23
|
+
module OneTimers
|
|
24
|
+
class #{class_name}
|
|
25
|
+
def run
|
|
26
|
+
# One-time task logic goes here.
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
RUBY
|
|
31
|
+
|
|
32
|
+
path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.task_files
|
|
36
|
+
Dir.glob(Onetimer.tasks_dir.join("*.rb")).sort
|
|
37
|
+
end
|
|
38
|
+
private_class_method :task_files
|
|
39
|
+
|
|
40
|
+
def self.run_task(file)
|
|
41
|
+
name = File.basename(file, ".rb")
|
|
42
|
+
return if Task.exists?(name: name, status: "completed")
|
|
43
|
+
|
|
44
|
+
task = claim(name)
|
|
45
|
+
return unless task # already completed or claimed by another process
|
|
46
|
+
|
|
47
|
+
require file
|
|
48
|
+
class_for(file).new.run
|
|
49
|
+
task.update!(status: "completed", finished_at: Time.current)
|
|
50
|
+
Rails.logger.info "[Onetimer] completed #{name}"
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
task&.destroy!
|
|
53
|
+
Rails.logger.error "[Onetimer] failed #{name}: #{e.message}"
|
|
54
|
+
raise
|
|
55
|
+
end
|
|
56
|
+
private_class_method :run_task
|
|
57
|
+
|
|
58
|
+
# Unique index on name makes this an atomic claim: if two processes
|
|
59
|
+
# (e.g. concurrent deploy machines) race to run the same task, only
|
|
60
|
+
# one succeeds in creating the row and actually runs it.
|
|
61
|
+
def self.claim(name)
|
|
62
|
+
Task.create!(name: name, status: "running", started_at: Time.current)
|
|
63
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
private_class_method :claim
|
|
67
|
+
|
|
68
|
+
def self.class_for(file)
|
|
69
|
+
class_name = File.basename(file, ".rb").sub(/\A\d+_/, "").camelize
|
|
70
|
+
"OneTimers::#{class_name}".constantize
|
|
71
|
+
end
|
|
72
|
+
private_class_method :class_for
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/onetimer.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :onetimer do
|
|
4
|
+
desc "Run any pending one-off tasks (idempotent, safe to run repeatedly)"
|
|
5
|
+
task run: :environment do
|
|
6
|
+
Onetimer::Runner.run_pending!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Generate a new one-off task, e.g. rake onetimer:new NAME=backfill_x"
|
|
10
|
+
task new: :environment do
|
|
11
|
+
name = ENV.fetch("NAME") { abort "Usage: rake onetimer:new NAME=backfill_x" }
|
|
12
|
+
path = Onetimer::Runner.generate!(name)
|
|
13
|
+
puts "Created #{path}"
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: onetimer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Zack Kitzmiller
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
description: A tiny Rails engine that gives you a lib/one_timers/ directory of run-once
|
|
28
|
+
data tasks, tracked in the database so each one runs exactly once across any number
|
|
29
|
+
of deploy machines.
|
|
30
|
+
email:
|
|
31
|
+
- zackkitzmiller@gmail.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- ".github/workflows/ci.yml"
|
|
37
|
+
- ".github/workflows/publish.yml"
|
|
38
|
+
- README.md
|
|
39
|
+
- Rakefile
|
|
40
|
+
- app/models/onetimer/task.rb
|
|
41
|
+
- justfile
|
|
42
|
+
- lib/generators/onetimer/install/install_generator.rb
|
|
43
|
+
- lib/generators/onetimer/install/templates/create_onetimer_tasks.rb.erb
|
|
44
|
+
- lib/onetimer.rb
|
|
45
|
+
- lib/onetimer/engine.rb
|
|
46
|
+
- lib/onetimer/runner.rb
|
|
47
|
+
- lib/onetimer/version.rb
|
|
48
|
+
- lib/tasks/onetimer.rake
|
|
49
|
+
homepage: https://github.com/z19r/onetimer
|
|
50
|
+
licenses:
|
|
51
|
+
- Nonstandard
|
|
52
|
+
metadata:
|
|
53
|
+
allowed_push_host: https://rubygems.org
|
|
54
|
+
homepage_uri: https://github.com/z19r/onetimer
|
|
55
|
+
source_code_uri: https://github.com/z19r/onetimer
|
|
56
|
+
post_install_message:
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.2.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.4.19
|
|
72
|
+
signing_key:
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: Runs one-off data tasks exactly once, the way db:migrate runs schema migrations.
|
|
75
|
+
test_files: []
|