Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions app/controllers/api/v1/claims_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Phase B of the dispatcher + claims feature
# (docs/dispatcher-and-claims.md). Records a computer's intent to
# do a piece of work — either build a commit (`scope='build'`) or
# run one test case on a commit (`scope='test'`) — and starts the
# wall-clock TTL via `Claim.default_expires_at`. The dispatcher
# endpoint that *recommends* what to claim lives in Phase C and is
# deliberately separate from this one (see "Dispatch vs. claim
# creation" in the design doc).
#
# Authentication mirrors `SubmissionsController` exactly: a
# `submitter:` hash carrying `email` / `password` / `computer`,
# with the password verified by bcrypt against the User and the
# computer scoped to the authenticated user's computers. The same
# `submitter_params` shape lets `mesa_test` reuse its existing
# credential plumbing.
module Api
module V1
class ClaimsController < ApplicationController
skip_before_action :authorize_user
skip_before_action :verify_authenticity_token, only: [:create]

def create
return unless authenticate_claim
return unless validate_scope

if @scope == 'test'
return unless resolve_test_case_commit
end

claim = Claim.new(
computer: @computer,
commit: @commit,
test_case_commit: @tcc,
scope: @scope,
status: 'pending',
use_fpe: bool_param(:use_fpe),
use_full_inlists: bool_param(:use_full_inlists),
use_converge: bool_param(:use_converge),
dispatched_at: parse_iso_datetime(claim_params[:dispatched_at]),
expires_at: Claim.default_expires_at(scope: @scope)
)

if claim.save
render json: { claim_id: claim.id,
expires_at: claim.expires_at.iso8601 },
status: :created
else
render json: { error: claim.errors.full_messages.join('; ') },
status: :unprocessable_content
end
end

private

# Same shape as SubmissionsController#authenticate_submission:
# verify the user, scope the computer to that user, and look
# up the commit by SHA. Returns true on success; sets a JSON
# error response and returns false on any failure.
def authenticate_claim
return claim_fail(:auth, 'Invalid e-mail or password.') unless authenticated?

@computer = @user.computers.find_by(name: submitter_params[:computer])
return claim_fail(:auth, "User #{@user.email} doesn't control computer " \
"#{submitter_params[:computer]}.") unless @computer

@commit = Commit.find_by(sha: claim_params[:commit_sha])
return claim_fail(:not_found,
"Unknown commit SHA: #{claim_params[:commit_sha]}.") unless @commit

true
end

def authenticated?
@user = current_user
return true if @user

@user = User.find_by(email: submitter_params[:email])
@user && @user.authenticate(submitter_params[:password])
end

def validate_scope
@scope = claim_params[:scope].to_s
return true if Claim::SCOPES.include?(@scope)

claim_fail(:bad_request,
"Invalid scope: #{@scope.inspect}. " \
"Must be one of #{Claim::SCOPES.inspect}.")
end

# For scope='test', the request carries a (module, name) pair
# identifying the test case. Look up the matching TCC on the
# claimed commit. Unlike the submissions path, this endpoint
# does NOT lazily create a TCC — TCCs are guaranteed to exist
# by the topology sync at commit ingest, so a missing one
# means either a stale client or a test case that doesn't
# apply to this commit, both of which the client should hear
# about explicitly.
def resolve_test_case_commit
mod = claim_params[:test_case_module].to_s
name = claim_params[:test_case_name].to_s
return claim_fail(:bad_request,
'test_case_module and test_case_name are required ' \
'for scope=test.') if mod.empty? || name.empty?

@tcc = @commit.test_case_commits
.joins(:test_case)
.find_by(test_cases: { module: mod, name: name })

return true if @tcc

claim_fail(:not_found,
"No test case commit found for #{mod}/#{name} on " \
"#{@commit.short_sha}.")
end

# Render an error and return false so callers can early-exit
# with `return unless ...`. Status codes:
# :auth → 422 (unprocessable_content) — matches the
# legacy submissions endpoint's auth-failure shape
# :not_found → 404 — for missing commit / TCC
# :bad_request → 422 — malformed request body
def claim_fail(kind, message)
status = case kind
when :not_found then :not_found
else :unprocessable_content
end
render json: { error: message }, status: status
false
end

def submitter_params
params.require(:submitter).permit(:email, :password, :computer)
end

def claim_params
params.require(:claim).permit(
:commit_sha, :scope,
:test_case_module, :test_case_name,
:use_fpe, :use_full_inlists, :use_converge,
:dispatched_at
)
end

# Coerces a JSON boolean (true/false/0/1/"true"/etc.) to a
# Ruby boolean. Returns false rather than nil when the key
# isn't present — claims' `use_*` columns are NOT NULL and
# default to false, so an absent key means "false," not
# "unknown."
def bool_param(key)
ActiveModel::Type::Boolean.new.cast(claim_params[key]) || false
end

def parse_iso_datetime(value)
return nil if value.blank?
Time.zone.parse(value.to_s)
rescue ArgumentError
nil
end
end
end
end
8 changes: 6 additions & 2 deletions app/controllers/commits_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ def index
.reorder('commits.commit_time ASC')
.limit(@page_size + 1)
.includes(:submissions,
test_case_commits: { test_instances: [] })
:pending_claims,
test_case_commits: [:test_instances,
:pending_claims])
.to_a
@has_more_newer = rows.size > @page_size
rows.pop if @has_more_newer
Expand All @@ -127,7 +129,9 @@ def index
.where('commits.commit_time < ?', @before_time_param)
.limit(@page_size + 1)
.includes(:submissions,
test_case_commits: { test_instances: [] })
:pending_claims,
test_case_commits: [:test_instances,
:pending_claims])
.to_a
@has_more_older_via_main_fetch = rows.size > @page_size
rows.pop if @has_more_older_via_main_fetch
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ def create
@submission.sdk_version = commit_params[:sdk_version]
@submission.math_backend = commit_params[:math_backend]

# Dispatcher + claims fulfillment payload (Phase B of
# docs/dispatcher-and-claims.md). Optional — backwards-
# compatible with mesa_test versions that don't send a
# `claim:` block. When a claim_id is supplied, the
# after_create_commit callback on Submission flips the
# matching claim to fulfilled. The `use_*` flags record what
# the work was actually run with (used by Phase C's
# satisfaction tracking).
assign_claim_fulfillment(@submission)

# only report compilation status once per go-round
# that is, if we're reporting results test-by-test, compilation information
# will come in an empty submission. In an entire submission, all the tests
Expand Down Expand Up @@ -291,4 +301,30 @@ def request_commit_params
params.permit(:allow_optional, :allow_fpe, :allow_converge, :allow_skip,
:max_age, :branch)
end

# Pulls the optional `claim:` block out of the request and
# writes its fields onto the submission. JSON shape:
#
# "claim": {
# "id": 8421, // integer, optional
# "started_at": "2026-05-27T12:34:56Z",
# "use_fpe": false,
# "use_full_inlists": false,
# "use_converge": false
# }
#
# No-op when no `claim:` key is present, so legacy mesa_test
# versions continue to work. The `id`-vs-`claim_id` rename keeps
# the JSON nested-namespace tidy (`claim.id`) while the column on
# `submissions` stays `claim_id`.
def assign_claim_fulfillment(submission)
return unless params[:claim]

attrs = params.require(:claim).permit(
:id, :started_at,
:use_fpe, :use_full_inlists, :use_converge
).to_h
attrs[:claim_id] = attrs.delete(:id) if attrs.key?(:id) || attrs.key?('id')
submission.assign_attributes(attrs)
end
end
116 changes: 116 additions & 0 deletions app/models/claim.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Records a computer's intent to do a specific piece of work
# (`scope='build'`: build a commit; `scope='test'`: run a single
# test case on a commit). See docs/dispatcher-and-claims.md for
# the design.
#
# Created via POST /api/v1/claims (Phase B) — the controller writes
# the row and starts the wall-clock TTL via
# `Claim.default_expires_at`. Fulfilled by an `after_create_commit`
# callback on Submission when a submission arrives carrying the
# claim's id; the same callback handles the `expired → fulfilled`
# transition for legitimately-late submissions. Pending claims past
# `expires_at` get swept to `expired` by `rake claims:sweep`.
#
# The CHECK constraint `claims_scope_fk_coherence` enforces the
# scope/TCC pairing at the database level; the model validation is
# the friendly user-facing version (returns a validation error
# instead of raising a Postgres exception).
class Claim < ApplicationRecord
SCOPES = %w[build test].freeze
STATUSES = %w[pending fulfilled expired].freeze

# V1 TTLs (docs/dispatcher-and-claims.md "TTLs"): builds are
# always quick, so 15 min is plenty; tests can legitimately run
# for hours, and short TTLs would produce noisy false
# expirations. Phase E swaps the test side for a historical-
# runtime calculation; the build side stays fixed because the
# easy case doesn't need help.
TTL_FOR_SCOPE = {
'build' => 15.minutes,
'test' => 12.hours
}.freeze

belongs_to :computer
belongs_to :commit
belongs_to :test_case_commit, optional: true

has_one :submission, dependent: :nullify

validates :scope, inclusion: { in: SCOPES }
validates :status, inclusion: { in: STATUSES }
validates :expires_at, presence: true

validate :scope_and_tcc_coherent
validate :tcc_commit_matches_claim_commit

scope :pending, -> { where(status: 'pending') }
scope :fulfilled, -> { where(status: 'fulfilled') }
scope :expired, -> { where(status: 'expired') }

# Wall-clock expiration for a freshly-created claim. The TTL
# constants live on this model so the controller doesn't
# duplicate them at the HTTP boundary.
def self.default_expires_at(scope:)
TTL_FOR_SCOPE.fetch(scope).from_now
end

# Forward-transition `pending → expired` for every claim whose
# `expires_at` has passed. Bulk UPDATE backed by
# `index_claims_on_expires_at_pending` (the partial index on
# `expires_at` scoped to `status = 'pending'`). Driven by
# `rake claims:sweep` from Railway cron; safe to call any time,
# including in-process from a spec.
#
# Returns the number of rows transitioned. Does NOT touch
# `fulfilled` claims (a late-submission reactivation isn't an
# expiration) or claims already swept.
def self.sweep_expired!(now: Time.current)
pending.where('expires_at < ?', now)
.update_all(status: 'expired', updated_at: now)
end

# Flip the claim to `fulfilled`. Idempotent across the two legal
# starting states (pending OR expired) — a late submission that
# arrives after the sweeper has flipped the claim to `expired`
# legitimately flips it back to `fulfilled` (lifecycle diagram in
# docs/dispatcher-and-claims.md). Uses update_columns so a stale
# `updated_at` race against the sweeper can't reject the write,
# and so AR validations on `status` never see the transient state.
def fulfill!(at: Time.current)
update_columns(status: 'fulfilled',
fulfilled_at: at,
updated_at: Time.current)
end

private

# Mirrors the `claims_scope_fk_coherence` CHECK constraint, but
# surfaces as an ActiveRecord validation error so callers see
# `Claim#errors` rather than an `ActiveRecord::StatementInvalid`.
def scope_and_tcc_coherent
case scope
when 'build'
if test_case_commit_id.present?
errors.add(:test_case_commit_id,
"must be blank for build-scope claims")
end
when 'test'
if test_case_commit_id.blank?
errors.add(:test_case_commit_id,
"is required for test-scope claims")
end
end
end

# `commit_id` is denormalized on every claim row so "all claims
# on this SHA" is a single index lookup, not a join. That
# convenience only holds if the claim's commit_id stays in sync
# with its TCC's commit_id — guard it here.
def tcc_commit_matches_claim_commit
return unless scope == 'test'
return if test_case_commit.blank?
return if test_case_commit.commit_id == commit_id
errors.add(:test_case_commit,
"must belong to the claim's commit")
end
end
Loading
Loading