このコミットが含まれているのは:
2026-10-26 21:24:32 +09:00
コミット 2563b3fef3
21個のファイルの変更、703行の追加、54行の削除
+236
ファイルの表示
@@ -0,0 +1,236 @@
require 'json'
require 'fileutils'
# A durable, bounded export spool. Web and supervised workers share the same POSIX volume.
class CountsExportJobs
class Cancelled < StandardError; end
ACTIVE = ['queued', 'running', 'cancelling'].freeze
def self.directory = CountsExportDownload.directory.join('jobs')
def self.verifier = Rails.application.message_verifier('counts_export_job')
def self.synchronise
FileUtils.mkdir_p(directory, mode: 0o700)
File.open(directory.join('.registry.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
lock.flock(File::LOCK_EX)
yield
end
end
def self.write(state)
path = directory.join("#{ state.fetch('id') }.json")
temporary = "#{ path }.#{ SecureRandom.hex(8) }.tmp"
File.open(temporary, 'w', 0o600) do |file|
file.write(JSON.generate(state))
file.flush
file.fsync
end
File.rename(temporary, path)
ensure
File.unlink(temporary) if temporary && File.exist?(temporary)
end
def self.read(id)
JSON.parse(File.read(directory.join("#{ id }.json")))
rescue Errno::ENOENT
nil
end
def self.remove_download(state)
name = state['download_file']
return unless name&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
File.unlink(CountsExportDownload.directory.join(name))
rescue Errno::ENOENT
nil
end
def self.remove_partial(id)
Dir.glob(CountsExportDownload.directory.join("counts-#{ id }-*")).each do |path|
File.unlink(path)
rescue Errno::ENOENT
nil
end
end
def self.clean
Dir.glob(directory.join('*.tmp')).each do |path|
File.unlink(path) if File.mtime(path) < Time.current - CountsExportDownload::EXPIRY
end
Dir.glob(directory.join('*.run')).each do |path|
id = File.basename(path, '.run')
next if directory.join("#{ id }.json").exist?
File.open(path, File::RDWR) do |lock|
File.unlink(path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
end
end
Dir.glob(directory.join('*.json')).each do |path|
state = JSON.parse(File.read(path))
next if state.fetch('expires_at') > Time.current.to_f
run = directory.join("#{ state.fetch('id') }.run")
File.open(run, File::RDWR | File::CREAT, 0o600) do |lock|
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
state['status'] = 'cancelling'
write(state)
next
end
remove_download(state)
remove_partial(state.fetch('id'))
File.unlink(path)
File.unlink(run)
end
end
end
def self.start(payload, owner, language)
payload = payload.dup
time_key = payload.key?('definition') ? 'execution_at' : 'at'
if payload[time_key].nil? || payload[time_key] == ''
payload[time_key] = Time.current.utc.iso8601(6)
end
exporter = CountsExporter.new(payload, language_code: language)
synchronise do
clean
active = Dir.glob(directory.join('*.json')).count do |path|
ACTIVE.include?(JSON.parse(File.read(path))['status'])
end
raise CountsExportDownload::Busy if active >= CountsExportDownload::MAX_CONCURRENT_EXPORTS
id = SecureRandom.hex(24)
state = { 'id' => id, 'owner' => owner, 'payload' => payload,
'language' => language, 'resource' => exporter.resource,
'format' => exporter.format, 'status' => 'queued',
'processed' => 0, 'total' => nil,
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
write(state)
verifier.generate({ 'id' => id, 'owner' => owner },
expires_in: CountsExportDownload::EXPIRY, purpose: 'counts_export_job')
end
end
def self.authorised_id(token, owner)
value = verifier.verified(token, purpose: 'counts_export_job')
return unless value.is_a?(Hash) && value['owner'] == owner
return unless value['id']&.match?(/\A[a-f0-9]{48}\z/)
value['id']
end
def self.status(token, owner, cancel: false)
id = authorised_id(token, owner)
return unless id
synchronise do
state = read(id)
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
remove_download(state)
state.delete('token')
write(state)
end
state.slice('status', 'processed', 'total', 'format', 'token', 'error')
end
end
def self.checkpoint(id, processed = nil, total = nil)
synchronise do
state = read(id)
unless state && state['status'] == 'running' && state['expires_at'] > Time.current.to_f
raise Cancelled
end
if processed
state.merge!('processed' => processed, 'total' => total)
write(state)
end
end
end
def self.work_one
FileUtils.mkdir_p(directory, mode: 0o700)
Dir.glob(directory.join('*.json')).sort.each do |path|
id = File.basename(path, '.json')
File.open(directory.join("#{ id }.run"), File::RDWR | File::CREAT, 0o600) do |lock|
next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
state = synchronise do
candidate = read(id)
next unless candidate && ACTIVE.include?(candidate['status'])
if candidate['status'] != 'queued'
candidate['status'] = candidate['status'] == 'cancelling' ? 'cancelled' : 'failed'
remove_partial(id)
candidate['error'] = 'interrupted'
write(candidate)
next
end
candidate['status'] = 'running'
write(candidate)
candidate
end
next unless state
perform(state)
return true
end
end
synchronise { clean }
false
end
def self.perform(state)
id = state.fetch('id')
token = nil
begin
checkpoint(id)
exporter = CountsExporter.new(state.fetch('payload'), language_code: state.fetch('language'))
last = Process.clock_gettime(Process::CLOCK_MONOTONIC)
progress = lambda do |processed, total|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
if processed.zero? || now - last >= 0.25 || processed == total
checkpoint(id, processed, total)
last = now
end
end
check = lambda do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
if now - last >= 0.25
checkpoint(id)
last = now
end
end
token = CountsExportDownload.prepare(exporter, state.fetch('owner'),
progress:, check:, identifier: id)
synchronise do
current = read(id)
if (!current || current['status'] != 'running' ||
current['expires_at'] <= Time.current.to_f)
file = CountsExportDownload.find(token, state.fetch('owner'))
File.unlink(file.fetch('path')) if file
raise Cancelled
end
file = CountsExportDownload.find(token, state.fetch('owner'))
current.merge!('status' => 'ready', 'token' => token,
'download_file' => File.basename(file.fetch('path')))
current['total'] = current['processed']
write(current)
end
rescue StandardError => error
remove_partial(id)
synchronise do
current = read(id)
if current
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
current['error'] = error.class.name
write(current)
end
end
unless error.is_a?(Cancelled)
Rails.logger.error("counts_export_job #{ id }: #{ error.class }")
end
end
end
end