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 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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetimer
4
+ class Task < ApplicationRecord
5
+ self.table_name = "onetimer_tasks"
6
+
7
+ validates :name, presence: true, uniqueness: true
8
+ validates :status, inclusion: { in: %w[running completed] }
9
+ end
10
+ end
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetimer
4
+ VERSION = "0.1.0"
5
+ end
data/lib/onetimer.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "onetimer/version"
4
+ require "onetimer/engine"
5
+ require "onetimer/runner"
6
+
7
+ module Onetimer
8
+ class Error < StandardError; end
9
+
10
+ class << self
11
+ attr_accessor :tasks_dir
12
+ end
13
+ end
@@ -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: []