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
14 changes: 14 additions & 0 deletions app/grpc/runtime_usage_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

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

def update(request, _call)
response = Runtimes::Grpc::RuntimeUsageUpdateService.new(usages: request.runtime_usage).execute

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

Tucana::Sagittarius::RuntimeUsageResponse.new(success: response.success?)
end
Comment on lines +7 to +13
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 @@ -79,6 +79,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' },
invalid_data_type_link: { description: 'The data type link is invalid because of active model errors' },
node_not_found: { description: 'The node with this id does not exist' },
Expand Down
108 changes: 108 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,108 @@
# frozen_string_literal: true

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

attr_reader :usages

def initialize(usages:)
@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?

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

db_usage = DailyRuntimeUsage.find_or_initialize_by(
namespace: flow.project.namespace,
flow: flow,
day: day
)
Comment on lines +32 to +44

return increment_usage(db_usage, amount) unless db_usage.persisted?

db_usage.with_lock { increment_usage(db_usage, amount) }
rescue ActiveRecord::RecordInvalid => e
invalid_usage_error(e.record.errors)
rescue ActiveRecord::RecordNotUnique
retry
Comment on lines +40 to +52
rescue ArgumentError
invalid_usage_error('Usage interval must be a valid date')
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 increment_usage(db_usage, amount)
db_usage.usage += amount
return ServiceResponse.success(payload: db_usage) if db_usage.save

invalid_usage_error(db_usage.errors)
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
14 changes: 14 additions & 0 deletions db/migrate/20260510081622_create_daily_runtime_usage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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

t.timestamps_with_timezone
Comment thread
raphael-goetz marked this conversation as resolved.
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
34 changes: 34 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ CREATE SEQUENCE backup_codes_id_seq

ALTER SEQUENCE backup_codes_id_seq OWNED BY backup_codes.id;

CREATE TABLE daily_runtime_usages (
id bigint NOT NULL,
flow_id bigint,
namespace_id bigint NOT NULL,
day date NOT NULL,
usage numeric DEFAULT 0.0 NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);

CREATE SEQUENCE daily_runtime_usages_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;

ALTER SEQUENCE daily_runtime_usages_id_seq OWNED BY daily_runtime_usages.id;

CREATE TABLE data_type_data_type_links (
id bigint NOT NULL,
data_type_id bigint NOT NULL,
Expand Down Expand Up @@ -888,6 +907,8 @@ ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_

ALTER TABLE ONLY backup_codes ALTER COLUMN id SET DEFAULT nextval('backup_codes_id_seq'::regclass);

ALTER TABLE ONLY daily_runtime_usages ALTER COLUMN id SET DEFAULT nextval('daily_runtime_usages_id_seq'::regclass);

ALTER TABLE ONLY data_type_data_type_links ALTER COLUMN id SET DEFAULT nextval('data_type_data_type_links_id_seq'::regclass);

ALTER TABLE ONLY data_type_rules ALTER COLUMN id SET DEFAULT nextval('data_type_rules_id_seq'::regclass);
Expand Down Expand Up @@ -979,6 +1000,9 @@ ALTER TABLE ONLY audit_events
ALTER TABLE ONLY backup_codes
ADD CONSTRAINT backup_codes_pkey PRIMARY KEY (id);

ALTER TABLE ONLY daily_runtime_usages
ADD CONSTRAINT daily_runtime_usages_pkey PRIMARY KEY (id);

ALTER TABLE ONLY data_type_data_type_links
ADD CONSTRAINT data_type_data_type_links_pkey PRIMARY KEY (id);

Expand Down Expand Up @@ -1134,6 +1158,10 @@ CREATE INDEX index_audit_events_on_author_id ON audit_events USING btree (author

CREATE UNIQUE INDEX "index_backup_codes_on_user_id_LOWER_token" ON backup_codes USING btree (user_id, lower(token));

CREATE INDEX index_daily_runtime_usages_on_flow_id ON daily_runtime_usages USING btree (flow_id);

CREATE INDEX index_daily_runtime_usages_on_namespace_id ON daily_runtime_usages USING btree (namespace_id);

CREATE INDEX index_data_type_rules_on_data_type_id ON data_type_rules USING btree (data_type_id);

CREATE UNIQUE INDEX index_data_types_on_runtime_id_and_identifier ON data_types USING btree (runtime_id, identifier);
Expand Down Expand Up @@ -1321,6 +1349,9 @@ ALTER TABLE ONLY namespace_member_roles
ALTER TABLE ONLY runtime_function_definition_data_type_links
ADD CONSTRAINT fk_rails_5a52fd74a0 FOREIGN KEY (referenced_data_type_id) REFERENCES data_types(id) ON DELETE RESTRICT;

ALTER TABLE ONLY daily_runtime_usages
ADD CONSTRAINT fk_rails_5bcc54b4a2 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE SET NULL;

ALTER TABLE ONLY namespace_role_project_assignments
ADD CONSTRAINT fk_rails_623f8a5b72 FOREIGN KEY (role_id) REFERENCES namespace_roles(id);

Expand Down Expand Up @@ -1381,6 +1412,9 @@ ALTER TABLE ONLY namespace_members
ALTER TABLE ONLY flows
ADD CONSTRAINT fk_rails_ab927e0ecb FOREIGN KEY (project_id) REFERENCES namespace_projects(id) ON DELETE CASCADE;

ALTER TABLE ONLY daily_runtime_usages
ADD CONSTRAINT fk_rails_b14fc7ae0a FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;

ALTER TABLE ONLY namespace_project_runtime_assignments
ADD CONSTRAINT fk_rails_c019e5b233 FOREIGN KEY (namespace_project_id) REFERENCES namespace_projects(id) ON DELETE CASCADE;

Expand Down
1 change: 1 addition & 0 deletions docs/graphql/enum/errorcodeenum.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Represents the available error responses
| `INVALID_RUNTIME_PARAMETER_DEFINITION` | The runtime parameter definition is invalid |
| `INVALID_RUNTIME_STATUS` | The runtime status is invalid because of active model errors |
| `INVALID_RUNTIME_STATUS_CONFIGURATION` | The runtime status configuration is invalid because of active model errors |
| `INVALID_RUNTIME_USAGE` | The runtime usage is invalid because of active model errors |
| `INVALID_SETTING` | Invalid setting provided |
| `INVALID_TOTP_SECRET` | The TOTP secret is invalid or cannot be verified |
| `INVALID_USER` | The user is invalid because of active model errors |
Expand Down
17 changes: 17 additions & 0 deletions docs/graphql/object/dailyruntimeusage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: DailyRuntimeUsage
---

Represents runtime usage for a flow on a specific day

## Fields without arguments

| Name | Type | Description |
|------|------|-------------|
| `createdAt` | [`Time!`](../scalar/time.md) | Time when this DailyRuntimeUsage was created |
| `day` | [`Date!`](../scalar/date.md) | The day this usage was recorded for |
| `flow` | [`Flow`](../object/flow.md) | The flow this usage was recorded for |
| `id` | [`DailyRuntimeUsageID!`](../scalar/dailyruntimeusageid.md) | Global ID of this DailyRuntimeUsage |
| `namespace` | [`Namespace!`](../object/namespace.md) | The namespace this usage belongs to |
| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this DailyRuntimeUsage was last updated |
| `usage` | [`Float!`](../scalar/float.md) | The accumulated runtime usage for the day |
Loading