Skip to content
Open
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
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