Slide 1

Slide 1 text

3回つくってわかった、 定期実行ジョブ基盤の本質情報 I Built a Scheduled Job Platform Three Times, and Now I Know the Essential Truths

Slide 2

Slide 2 text

Over ten years in engineering leadership, including roles as CTO and VP of Engineering. Now a Sta Engineer at IVRy, working on Staff Engineer, IVRy engineering organization. ff Naoto Takai product development and improving our

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

Remember yesterday's keynote?

Slide 7

Slide 7 text

DHH Matz Who?

Slide 8

Slide 8 text

A Look Back at the Early Ruby Days • Babie was an active Ruby community member in the 2000s. • In December 2004, He introduced O/R mapping tools in Rubyist Magazine. • My rorm was featured alongside Active Record, just six months after its release. https://magazine.rubyist.net/articles/0004/0004-

Slide 9

Slide 9 text

3回つくってわかった、 定期実行ジョブ基盤の本質情報 I Built a Scheduled Job Platform Three Times, and Now I Know the Essential Truths

Slide 10

Slide 10 text

ff Do you ever do business-y stu at work?

Slide 11

Slide 11 text

Business Applications Need Recurring Jobs • Business runs on cycles: • Transactions happen continuously. • Business periods repeat daily, weekly, monthly, and yearly. • Data has to be aggregated, reconciled, and nalized. • Systems exchange and synchronize data. fi Recurring jobs come out of business processes.

Slide 12

Slide 12 text

Rails Makes This Easy, Right? • Rails 8 + Solid Queue makes recurring jobs simple. # app/jobs/daily_report_job.rb class DailyReportJob < ApplicationJob queue_as :default def perform(*args) DailyReport.generate! end end # config/recurring.yml production: daily_report: class: DailyReportJob queue: default schedule: every day at 2am fi De ne a job, add a schedule, and you're done!

Slide 13

Slide 13 text

Real-World Jobs Are Often Work ows • A business process usually has several steps: 1. Call an external API 2. Fetch and validate data 3. Transform and store it 4. Call another service 5. Generate a report or send a noti cation fl fi fl A business process is a work ow, not a single job.

Slide 14

Slide 14 text

Scheduling Is Easy. Operations Are Hard. • What if the previous run is still running? • What if the job fails halfway through? • Can we safely rerun it? • Can we see what is running and what failed? • Who gets noti ed when something goes wrong? fi Recurring jobs need more than a scheduler.

Slide 15

Slide 15 text

How We Used Sidekiq at IVRy • We used Sidekiq Pro for event-driven async processing triggered by phone calls. • User-triggered jobs and scheduled jobs ran on Sidekiq. Rails App Queue Queue Worker Worker Worker Sidekiq Scheduler Different workloads shared the same workers.

Slide 16

Slide 16 text

Problems We Actually Hit • Case 1: fi Recurring jobs delayed user-triggered jobs around month-end and month-start. • Case 2: A recurring job sometimes started before its previous run had nished. • Case 3: When a job failed, we could not tell whether it was safe to rerun.

Slide 17

Slide 17 text

A Job Class Knows What, Not When • A job class knows what to do. • It doesn't know: • when it should run • whether another run is still going • what happened last time • whether it is safe to run again Scheduled jobs need execution management.

Slide 18

Slide 18 text

Not an Async Job with a Timer Async Job Scheduled Job Event-driven Time-driven Single task Sequential steps Many at once Only one (no overlap) Seconds to minutes Minutes to hours Retry whole job Retry failed steps History Optional History Required

Slide 19

Slide 19 text

I've Built This Three Times • Kuroko (Cookpad, 2012) • Distributed architecture with RabbitMQ, a work ow fl engine, and EC2 • Kuroko2 (Cookpad, 2014) • Monolithic architecture centered on the database • IVRy Job Scheduler (IVRy, 2026) • Serverless architecture built on AWS managed services

Slide 20

Slide 20 text

Kuroko • In 2012, I was young, and so was AWS.

Slide 21

Slide 21 text

Kuroko2 • Kuroko2 is built around a relational database, using a monolithic architecture to keep state management simple.

Slide 22

Slide 22 text

IVRy Job Scheduler • Built entirely on AWS managed services, it is serverless and requires little operational overhead. Console API Sidekiq Job Schedule Workflow Scripts Status

Slide 23

Slide 23 text

What Each Platform Was Built From • The responsibilities stayed the same, even when the fl technology changed. Kuroko Kuroko2 IVRy Job Scheduler rufus-scheduler Custom scheduler EventBridge Scheduler Ruote Custom work ow Step Functions EC2 workers Custom executor ECS / Lambda Fluentd CloudWatch Logs CloudWatch Logs / Datadog Custom console Custom console Custom console

Slide 24

Slide 24 text

The Same Five Components Keep Appearing • All three have a scheduler, a work ow engine, an executor, logs, and a console. Scheduler Workflow Executor Console fl Logs

Slide 25

Slide 25 text

Simple Intent, Complex Reality • What developers want: Run this job every day at 2 AM. • Then the questions start: • Where does it run? What if it runs twice? Who owns the schedule? What if it fails? Retry? How many times? When do we give up? What state is it in? Where are the logs? What happened last night? How do we recover? Rerun everything? Just one step? Who gets noti ed? fi The gap creates cognitive load for application developers.

Slide 26

Slide 26 text

Cognitive Load Hurts DevEx • The DevEx Framework identi es three core dimensions: Flow State DevEx Cognitive Load Feedback Loops fi Adapted from Noda et al., “DevEx: What Actually Drives Productivity,” ACM Queue, 2023.

Slide 27

Slide 27 text

We need to build scheduled job infrastructure as a platform.

Slide 28

Slide 28 text

Platform Engineering Bridges Intent and Reality Run this job every day at 2 AM. Platform Developer Intent Workload Concurrency Logs Scheduling Retries Recovery State Timeouts Notifications Developers express intent. The platform handles reality.

Slide 29

Slide 29 text

How we built it in the IVRy Job Scheduler

Slide 30

Slide 30 text

No content

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

How Application Developers De ne Jobs • Creating a job takes three steps: fi fi 1. Write a normal Sidekiq job 2. De ne the job with the DSL 3. Open and merge a pull request

Slide 33

Slide 33 text

Write A Normal Sidekiq Job • Application developers reuse existing Sidekiq jobs and keep working in Rails. class DailyReportJob < ApplicationJob include Sidekiq::Job def perform(*args) DailyReport.generate! end end

Slide 34

Slide 34 text

De ne The Job With The DSL • Developers write the intent in Jsonnet. fi function(dsl, jobId) dsl.Job(jobId, { steps: [ dsl.SidekiqTask('DailyReportJob'), ], schedules: [ { expression: 'cron(0 2 * * ? *)' }, ], execution_policy: 'SKIP_IF_RUNNING', })

Slide 35

Slide 35 text

Open and Merge a Pull Request • Merging the de nition updates the platform. Terraform JSON Apply AWS Resources Generate Job Definition Jsonnet Generate fi Job Metadata JSON Import Web Console

Slide 36

Slide 36 text

Three Problems, Three Responsibilities • What the platform must handle • Case1: Recurring jobs delayed event-driven jobs fi → Isolate workloads • Case2: A job started before the previous run nished → Prevent overlapping runs • Case3: We couldn't tell if a failed job was safe to rerun → Make failures visible and recovery easy

Slide 37

Slide 37 text

How a Scheduled Job Runs • The platform executes existing Sidekiq jobs through a thin runner. GitHub EventBridge Scheduler Trigger Step Functions CI / CD ECR Run Task Sidekiq Runner on ECS Fargate Invoke Sidekiq Job

Slide 38

Slide 38 text

A Thin Runner Connects the Platform to Rails • The runner loads Rails and executes an existing Sidekiq job directly. # Simplified version require "bundler/setup" require_relative "../config/environment" job_class = Object.const_get(ARGV[0]) job_class.perform_inline(*ARGV.drop(1))

Slide 39

Slide 39 text

Three Problems, Three Responsibilities • What the platform must handle • Case1: Recurring jobs delayed user-facing jobs fi → Isolate workloads • Case2: A job started before the previous run nished → Prevent overlapping runs • Case3: We couldn't tell if a failed job was safe to rerun → Make failures visible and recovery easy

Slide 40

Slide 40 text

A Few Lines of Intent Become a Full Work ow • A short DSL de nition expands into hundreds of lines of work ow JSON with state checks, retries, and execution tracking. { "ResultPath": "$.prepared", "Type": "Pass" }, { }, "SetRunningState": { "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "EnsureDatadogContext", "ResultPath": "$.error" } ], "Next": "EnsureDatadogContext", "Parameters": { "Item": { "executionArn.$": "$$.Execution.Id", "jobName": { "S.$": "$$.StateMachine.Name" }, "lastUpdated.$": "$$.State.EnteredTime", "startTime.$": "$$.Execution.StartTime", "status": { "S": "RUNNING" }, "stopTime": { "S": "" } }, "TableName": "job-scheduler-state" }, "Resource": "arn:aws:states:::dynamodb:putItem", "ResultPath": "$.putResult", "Retry": [ { "BackoffRate": 2, "ErrorEquals": [ "DynamoDB.SdkClientException", "DynamoDB.AmazonServiceException", "DynamoDB.InternalServerError", "DynamoDB.ServiceUnavailable", "IsPresent": true, "Variable": "$.dbResult.Item.status.S" }, { "Or": [ { "StringEquals": "FAILED", "Variable": "$.dbResult.Item.status.S" }, { "StringEquals": "TIMED_OUT", "Variable": "$.dbResult.Item.status.S" }, { "StringEquals": "ABORTED", "Variable": "$.dbResult.Item.status.S" } ] } ], "Next": "SkipExecution" } ], "Default": "SetRunningState", "Type": "Choice" }, "EnsureDatadogContext": { "Next": "PrepareTraceContext", "OutputPath": "$.result", "Parameters": { "result.$": "States.JsonMerge(States.StringToJson('{\"_datadog\":{}}'), $, false)" }, "Type": "Pass" }, "GetJobState": { "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "SetRunningState", "ResultPath": "$.error" } ], "Next": "CheckExecutionPolicy", "Parameters": { "Key": { "jobName": { "S.$": "$$.StateMachine.Name" } }, "TableName": "job-scheduler-state" }, "Resource": "arn:aws:states:::dynamodb:getItem", "ResultPath": "$.dbResult", "Type": "Task" }, "PrepareTraceContext": { "Next": "SidekiqTask-Contracts::DailyReportJob", "Parameters": { "ddTraceContext.$": "States.JsonToString($ ['_datadog'])" }, "DynamoDB.ProvisionedThroughputExceededException", "DynamoDB.RequestLimitExceeded", "DynamoDB.ThrottlingException" ], "IntervalSeconds": 1, "JitterStrategy": "FULL", "MaxAttempts": 3 } ], "Type": "Task" }, "SidekiqTask-Contracts::DailyReportJob": { "End": true, "Parameters": { "Cluster": "arn:aws:ecs:apnortheast-1:146154011163:cluster/job-scheduler", "LaunchType": "FARGATE", "NetworkConfiguration": { "AwsvpcConfiguration": { "AssignPublicIp": "ENABLED", "SecurityGroups": [ "sg-00ddd5fe5b773d7a4" ], "Subnets": [ "subnet-064f8c06baca91b95", "subnet-02544c3bc2d96f992", fl fi fl function(dsl, jobId) dsl.Job(jobId, { steps: [ dsl.SidekiqTask('DailyReportJob'), ], schedules: [ { expression: 'cron(0 9 * * ? *)' }, ], execution_policy: 'SKIP_IF_RUNNING', }) "Variable": "$.executionPolicy" "Comment": "Daily Report Job execution workflow", "StartAt": "GetJobState", "States": { "CheckExecutionPolicy": { "Choices": [ { "Next": "SetRunningState", "StringEquals": "ALWAYS_RUN", "Variable": "$.executionPolicy" }, { "Next": "SetRunningState", "Not": { "IsPresent": true, "Variable": "$.dbResult.Item.status" } }, { "And": [ { "StringEquals": "SKIP_IF_RUNNING_OR_FAILED", "Variable": "$.executionPolicy" }, { "IsPresent": true, "Variable": "$.dbResult.Item.status.S" }, { "Or": [ { "StringEquals": "RUNNING", "Variable": "$.dbResult.Item.status.S" }, { "StringEquals": "FAILED", "Variable": "$.dbResult.Item.status.S" }, { "StringEquals": "TIMED_OUT", "Variable": "$.dbResult.Item.status.S" }, { "StringEquals": "ABORTED", "Variable": "$.dbResult.Item.status.S" } ] } ], "Next": "SkipExecution" }, { "And": [ { "StringEquals": "SKIP_IF_RUNNING", "Variable": "$.executionPolicy" }, { "StringEquals": "RUNNING", "Variable": "$.dbResult.Item.status.S" } ], "Next": "SkipExecution" }, { "And": [ { "StringEquals": "SKIP_IF_FAILED", "subnet-09bc46d9ba952162f" ] } }, "Overrides": { "ContainerOverrides": [ { "Command": [ "./scripts/sidekiq-runner", "Contracts::DailyReportJob" ], "Environment": [ { "Name": "JOB_EXECUTION_ARN", "Value.$": "$$.Execution.Id" }, { "Name": "JOB_START_TIME", "Value.$": "$$.Execution.StartTime" }, { "Name": "DD_TRACE_CONTEXT", "Value.$": "$.prepared.ddTraceContext" } ], "Name": "first" } ] }, "TaskDefinition": "arn:aws:ecs:apnortheast-1:146154011163:task-definition/pro-ivry-job-runneroneshot" }, "Resource": "arn:aws:states:::ecs:runTask.sync", "ResultPath": "$.ecsResult", "Retry": [ { "BackoffRate": 2, "ErrorEquals": [ "ECS.ServerException", "ECS.SdkClientException" ], "IntervalSeconds": 10, "JitterStrategy": "FULL", "MaxAttempts": 3 } ], "Type": "Task" }, "SkipExecution": { "End": true, "Parameters": { "executionPolicy.$": "$.executionPolicy", "jobName.$": "$.jobName", "skipResult": { "prevStatus.$": "$.dbResult.Item.status.S", "reason.$": "States.Format('Skipped due to previous status: {}', $.dbResult.Item.status.S)", "skipped": true }, "triggerType.$": "$.triggerType", "triggeredBy.$": "$.triggeredBy" }, "Type": "Pass" } } }

Slide 41

Slide 41 text

A Few Lines of Intent Become a Full Work ow • A short DSL de nition expands into hundreds of lines of work ow JSON with state checks, retries, and execution tracking. Start Choice state CheckEventStatus $.detail.status == "SUCCEEDED" and $.detail.outputDetails.included Default== true Pass state Pass state ExtractWithOutput ExtractWithoutOutput Pass state ParseTriggerContext Choice state CheckIfSkipped $.status == "FAILED" function(dsl, jobId) dsl.Job(jobId, { steps: [ dsl.SidekiqTask('DailyReportJob'), ], schedules: [ { expression: 'cron(0 9 * * ? *)' }, ], execution_policy: 'SKIP_IF_RUNNING', }) Start AWS Step Functions: DescribeExecution DescribeFailedExecution Amazon DynamoDB: GetItem GetJobState Choice state CheckForInfraFailureRedrive Choice state CheckExecutionPolicy $.executionPolicy == "ALWAYS_RUN" not($.dbResult.Item.status is present) Default Default $.executionDetails.Cause is present and $.executionDetails.Cause ~= "*TaskFailedToStart*" and $.executionDetails.RedriveCount Catch #1 <2 Catch #1 AWS Step Functions: RedriveExecution $.executionPolicy == "SKIP_IF_RUNNING_OR_FAILED" $.executionPolicy and $.dbResult.Item.status.S == "SKIP_IF_RUNNING" is present and $.executionPolicy $.dbResult.Item.status.S and ($.dbResult.Item.status.S == "SKIP_IF_FAILED" == "RUNNING" == "RUNNING" and $.dbResult.Item.status.S or $.dbResult.Item.status.S is present == and"FAILED" ($.dbResult.Item.status.S or $.dbResult.Item.status.S == "FAILED" ==or "TIMED_OUT" $.dbResult.Item.status.S or $.dbResult.Item.status.S == "TIMED_OUT" ==or "ABORTED") $.dbResult.Item.status.S == "ABORTED") Default $.parsedOutput.skipResult.skipped is present and $.parsedOutput.skipResult.skipped == true RedriveOriginalExecution Amazon DynamoDB: PutItem Pass state SetRunningState SkipExecution Catch #1 Amazon DynamoDB: PutItem Catch #1 UpdateJobState Pass state EnsureDatadogContext Catch #1 Amazon DynamoDB: PutItem WriteExecutionHistory Pass state PrepareTraceContext Catch #1 Amazon ECS: RunTask SidekiqTask-HealthCheckJob $.status == "FAILED" End Choice state Amazon DynamoDB: PutItem CheckIfFailed WriteSkippedHistory $.status == "TIMED_OUT" $.status == "ABORTED" fl fi Catch #1 Amazon SNS: Publish Succeed state SendFailureNotification Success End fl Default

Slide 42

Slide 42 text

Three Problems, Three Responsibilities • What the platform must handle • Case1: Recurring jobs delayed user-facing jobs fi → Isolate workloads • Case2: A job started before the previous run nished → Prevent overlapping runs • Case3: We couldn't tell if a failed job was safe to rerun → Make failures visible and recovery easy

Slide 43

Slide 43 text

Failures Go Straight to Slack • Failed jobs are reported to Slack.

Slide 44

Slide 44 text

Recover from the Portal • From the alert, operators open the failed execution in the portal.

Slide 45

Slide 45 text

Operate Jobs from the Portal • The portal has what anyone needs to operate a job.

Slide 46

Slide 46 text

What I Learned After Building It Three Times 1. Scheduled jobs come with their own operational challenges. 2. There’s a gap between what developers want and what reliable execution requires. 3. A platform can close that gap. If operating scheduled jobs is getting painful, consider building a platform.

Slide 47

Slide 47 text

Takeaways • Every domain has its own complexity. • That complexity shapes the architecture. • A platform turns developer intent into working systems.