Skip to content
18 changes: 18 additions & 0 deletions app/graphql/types/daily_runtime_usage_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module Types
class DailyRuntimeUsageType < Types::BaseObject
description 'Represents runtime usage for a flow on a specific day'

authorize :read_namespace
declarative_policy_subject(&:namespace)

field :day, Types::DateType, null: false, description: 'The day this usage was recorded for'
field :flow, Types::FlowType, null: true, description: 'The flow this usage was recorded for'
field :namespace, Types::NamespaceType, null: false, description: 'The namespace this usage belongs to'
field :usage, Float, null: false, description: 'The accumulated runtime usage for the day'

id_field DailyRuntimeUsage
timestamps
end
end
23 changes: 23 additions & 0 deletions app/graphql/types/date_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Types
class DateType < BaseScalar
description <<~DESC
Date represented in ISO 8601.

For example: "2026-05-12".
DESC

def self.coerce_input(value, _ctx)
return if value.nil?

Date.iso8601(value)
rescue ArgumentError, TypeError => e
raise GraphQL::CoercionError, e.message
end

def self.coerce_result(value, _ctx)
value.iso8601
end
end
end
18 changes: 18 additions & 0 deletions app/graphql/types/flow_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ class FlowType < Types::BaseObject

authorize :read_flow

field :daily_runtime_usages, Types::DailyRuntimeUsageType.connection_type,
null: false,
description: 'Daily runtime usage entries for this flow' do
argument :from, Types::DateType,
required: false,
description: 'Only return usage entries on or after this day'
argument :to, Types::DateType,
required: false,
description: 'Only return usage entries on or before this day'
end

field :name, String, null: false, description: 'Name of the flow'

field :disabled_reason, Types::FlowDisabledReasonEnum,
Expand Down Expand Up @@ -56,5 +67,12 @@ def starting_node_id
def linked_data_types
DataTypesFinder.new({ flow: object, expand_recursively: true }).execute
end

def daily_runtime_usages(from: nil, to: nil)
scope = object.daily_runtime_usages.order(day: :desc, id: :desc)
scope = scope.where(DailyRuntimeUsage.arel_table[:day].gteq(from)) if from.present?
scope = scope.where(DailyRuntimeUsage.arel_table[:day].lteq(to)) if to.present?
scope
end
end
end
2 changes: 1 addition & 1 deletion app/graphql/types/namespace_project_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class NamespaceProjectType < Types::BaseObject
timestamps

def flow(id:)
object.flows.find(id: id)
object.flows.find_by(id: id.model_id)
end
end
end
21 changes: 21 additions & 0 deletions app/graphql/types/namespace_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ class NamespaceType < Types::BaseObject
description: 'Members of the namespace',
extras: [:lookahead]

field :daily_runtime_usages, Types::DailyRuntimeUsageType.connection_type,
null: false,
description: 'Daily runtime usage entries for this namespace' do
argument :flow_id, Types::GlobalIdType[::Flow],
required: false,
description: 'Only return usage entries for this flow'
argument :from, Types::DateType,
required: false,
description: 'Only return usage entries on or after this day'
argument :to, Types::DateType,
required: false,
description: 'Only return usage entries on or before this day'
end
field :roles, Types::NamespaceRoleType.connection_type, null: false, description: 'Roles of the namespace'
field :runtimes, Types::RuntimeType.connection_type, null: false, description: 'Runtime of the namespace'

Expand All @@ -39,6 +52,14 @@ class NamespaceType < Types::BaseObject
def project(id:)
object.projects.find_by(id: id.model_id)
end

def daily_runtime_usages(flow_id: nil, from: nil, to: nil)
scope = object.daily_runtime_usages.order(day: :desc, id: :desc)
scope = scope.where(flow_id: flow_id.model_id) if flow_id.present?
scope = scope.where(DailyRuntimeUsage.arel_table[:day].gteq(from)) if from.present?
scope = scope.where(DailyRuntimeUsage.arel_table[:day].lteq(to)) if to.present?
scope
end
end
end

Expand Down
18 changes: 18 additions & 0 deletions app/grpc/runtime_usage_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

class RuntimeUsageHandler < Tucana::Sagittarius::RuntimeUsageService::Service
include Code0::ZeroTrack::Loggable
include GrpcHandler

def update(request, _call)
current_runtime = Runtime.find(Code0::ZeroTrack::Context.current[:runtime][:id])
response = Runtimes::Grpc::RuntimeUsageUpdateService.new(
runtime: current_runtime,
usages: request.runtime_usage
).execute

logger.debug("RuntimeUsageHandler#update response: #{response.inspect}")

Tucana::Sagittarius::RuntimeUsageResponse.new(success: response.success?)
end
Comment thread
raphael-goetz marked this conversation as resolved.
end
9 changes: 9 additions & 0 deletions app/models/daily_runtime_usage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class DailyRuntimeUsage < ApplicationRecord
belongs_to :flow, optional: true, inverse_of: :daily_runtime_usages
belongs_to :namespace, inverse_of: :daily_runtime_usages

validates :day, presence: true
validates :usage, numericality: { greater_than_or_equal_to: 0 }
end
1 change: 1 addition & 0 deletions app/models/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Flow < ApplicationRecord

has_many :flow_settings, class_name: 'FlowSetting', inverse_of: :flow
has_many :node_functions, class_name: 'NodeFunction', inverse_of: :flow
has_many :daily_runtime_usages, inverse_of: :flow

has_many :flow_data_type_links, inverse_of: :flow
has_many :referenced_data_types, through: :flow_data_type_links, source: :referenced_data_type
Expand Down
1 change: 1 addition & 0 deletions app/models/namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Namespace < ApplicationRecord
has_many :projects, class_name: 'NamespaceProject', inverse_of: :namespace

has_many :runtimes, inverse_of: :namespace
has_many :daily_runtime_usages, inverse_of: :namespace

def organization_type?
parent_type == Organization.name
Expand Down
1 change: 1 addition & 0 deletions app/services/error_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def self.error_codes
invalid_data_type: { description: 'The data type is invalid because of active model errors' },
data_type_not_found: { description: 'The data type with the given identifier was not found' },
invalid_flow_type: { description: 'The flow type is invalid because of active model errors' },
invalid_runtime_usage: { description: 'The runtime usage is invalid because of active model errors' },
no_data_type_for_identifier: { description: 'No data type could be found for the given identifier' },
cyclic_data_type_reference: { description: 'A data type dependency cycle was detected' },
invalid_data_type_link: { description: 'The data type link is invalid because of active model errors' },
Expand Down
136 changes: 136 additions & 0 deletions app/services/runtimes/grpc/runtime_usage_update_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

module Runtimes
module Grpc
class RuntimeUsageUpdateService
include Sagittarius::Database::Transactional
include Code0::ZeroTrack::Loggable

attr_reader :runtime, :usages

def initialize(runtime:, usages:)
@runtime = runtime
@usages = usages
end

def execute
transactional do |t|
updated_usages = []

Array.wrap(usages).each do |usage|
result = update_usage(usage)
t.rollback_and_return! result if result.error?

updated_usages << result.payload
end

ServiceResponse.success(message: 'Updated runtime usage', payload: updated_usages)
end
end

private

def update_usage(usage)
flow = Flow.includes(project: :namespace).find_by(id: usage_attribute(usage, :flow_id))
return ServiceResponse.error(message: 'Flow not found', error_code: :flow_not_found) if flow.nil?
return runtime_assignment_error(flow) unless runtime_assigned_to_flow?(flow)

day = usage_day(usage)
amount = usage_amount(usage)
return invalid_usage_error('Usage amount must be greater than zero') unless amount&.positive?

db_usage, created = find_or_create_usage(flow, day, amount)

return ServiceResponse.success(payload: db_usage) if created

# rubocop:disable Rails/SkipsModelValidations -- amount is validated above; this keeps the increment atomic in SQL.
DailyRuntimeUsage.update_counters(db_usage.id, usage: amount, touch: true)
# rubocop:enable Rails/SkipsModelValidations
ServiceResponse.success(payload: db_usage.reload)
rescue ActiveRecord::RecordInvalid => e
invalid_usage_error(e.record.errors)
rescue ArgumentError
invalid_usage_error('Usage interval must be a valid date')
end

def find_or_create_usage(flow, day, amount)
attributes = {
namespace: flow.project.namespace,
flow: flow,
day: day,
}

db_usage = DailyRuntimeUsage.find_by(attributes)
return [db_usage, false] if db_usage.present?

db_usage = nil
DailyRuntimeUsage.transaction(requires_new: true) do
db_usage = DailyRuntimeUsage.create!(attributes.merge(usage: amount))
end

[db_usage, true]
rescue ActiveRecord::RecordNotUnique
retry
end

def usage_day(usage)
value = usage_attribute(usage, :day, :date, :interval)
return Time.zone.today if value.nil?

case value
when Date
value
when Time
value.to_date
when String
Date.iso8601(value)
else
Time.zone.at(value.seconds).to_date if value.respond_to?(:seconds)
end
end

def usage_amount(usage)
value = usage_attribute(usage, :duration, :usage, :amount, :count)
return if value.nil?

BigDecimal(value.to_s)
rescue ArgumentError
nil
end

def runtime_assigned_to_flow?(flow)
runtime.project_assignments.compatible.exists?(namespace_project: flow.project)
end

def runtime_assignment_error(flow)
assignment = runtime.project_assignments.find_by(namespace_project: flow.project)
if assignment.nil?
return ServiceResponse.error(
message: 'Runtime not assigned to flow project',
error_code: :runtime_not_assigned
)
end

ServiceResponse.error(message: 'Runtime not compatible with flow project', error_code: :runtime_not_compatible)
end

def usage_attribute(usage, *keys)
keys.each do |key|
return usage.public_send(key) if usage.respond_to?(key)
return usage[key] if usage.respond_to?(:key?) && usage.key?(key)
return usage[key.to_s] if usage.respond_to?(:key?) && usage.key?(key.to_s)
end

nil
end

def invalid_usage_error(details)
ServiceResponse.error(
message: 'Failed to update runtime usage',
error_code: :invalid_runtime_usage,
details: details
)
end
end
end
end
18 changes: 18 additions & 0 deletions db/migrate/20260510081622_create_daily_runtime_usage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

class CreateDailyRuntimeUsage < Code0::ZeroTrack::Database::Migration[1.0]
def change
create_table :daily_runtime_usages do |t|
t.references :flow, null: true, foreign_key: { to_table: :flows, on_delete: :nullify }
t.references :namespace, null: false, foreign_key: { to_table: :namespaces, on_delete: :cascade }
t.date :day, null: false
t.decimal :usage, null: false, default: 0

Comment thread
raphael-goetz marked this conversation as resolved.
t.timestamps_with_timezone
Comment thread
raphael-goetz marked this conversation as resolved.

t.index %i[namespace_id flow_id day], unique: true
t.index %i[namespace_id day]
t.index %i[flow_id day]
end
end
end
1 change: 1 addition & 0 deletions db/schema_migrations/20260510081622
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b3cbb8e82f5a5fe001575d6c8ae8c27c15878108b650ec216f25aee8a00894f9
Loading