From f378e4f72a62e7a311bcf297a9cfd720c2b86ea8 Mon Sep 17 00:00:00 2001 From: Raphael Date: Sun, 10 May 2026 11:33:53 +0200 Subject: [PATCH 1/9] feat: created weekly runtime usage migration --- ...60510081622_create_weekly_runtime_usage.rb | 15 ++++++++ db/schema_migrations/20260510081622 | 1 + db/structure.sql | 35 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 db/migrate/20260510081622_create_weekly_runtime_usage.rb create mode 100644 db/schema_migrations/20260510081622 diff --git a/db/migrate/20260510081622_create_weekly_runtime_usage.rb b/db/migrate/20260510081622_create_weekly_runtime_usage.rb new file mode 100644 index 00000000..f4d01238 --- /dev/null +++ b/db/migrate/20260510081622_create_weekly_runtime_usage.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateWeeklyRuntimeUsage < Code0::ZeroTrack::Database::Migration[1.0] + def change + create_table :weekly_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 :week_start + t.date :week_end + t.decimal :usage + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20260510081622 b/db/schema_migrations/20260510081622 new file mode 100644 index 00000000..78949017 --- /dev/null +++ b/db/schema_migrations/20260510081622 @@ -0,0 +1 @@ +b3cbb8e82f5a5fe001575d6c8ae8c27c15878108b650ec216f25aee8a00894f9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6457a847..6f56d6a8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -876,6 +876,26 @@ CREATE SEQUENCE users_id_seq ALTER SEQUENCE users_id_seq OWNED BY users.id; +CREATE TABLE weekly_runtime_usages ( + id bigint NOT NULL, + flow_id bigint, + namespace_id bigint NOT NULL, + week_start date, + week_end date, + usage numeric, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE weekly_runtime_usages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE weekly_runtime_usages_id_seq OWNED BY weekly_runtime_usages.id; + ALTER TABLE ONLY active_storage_attachments ALTER COLUMN id SET DEFAULT nextval('active_storage_attachments_id_seq'::regclass); ALTER TABLE ONLY active_storage_blobs ALTER COLUMN id SET DEFAULT nextval('active_storage_blobs_id_seq'::regclass); @@ -958,6 +978,8 @@ ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_session ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); +ALTER TABLE ONLY weekly_runtime_usages ALTER COLUMN id SET DEFAULT nextval('weekly_runtime_usages_id_seq'::regclass); + ALTER TABLE ONLY active_storage_attachments ADD CONSTRAINT active_storage_attachments_pkey PRIMARY KEY (id); @@ -1102,6 +1124,9 @@ ALTER TABLE ONLY user_sessions ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY weekly_runtime_usages + ADD CONSTRAINT weekly_runtime_usages_pkey PRIMARY KEY (id); + CREATE UNIQUE INDEX idx_on_data_type_id_referenced_data_type_id_bb9b090c90 ON data_type_data_type_links USING btree (data_type_id, referenced_data_type_id); CREATE UNIQUE INDEX idx_on_flow_id_referenced_data_type_id_14b02b52f8 ON flow_data_type_links USING btree (flow_id, referenced_data_type_id); @@ -1264,6 +1289,10 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower( CREATE UNIQUE INDEX index_users_on_totp_secret ON users USING btree (totp_secret) WHERE (totp_secret IS NOT NULL); +CREATE INDEX index_weekly_runtime_usages_on_flow_id ON weekly_runtime_usages USING btree (flow_id); + +CREATE INDEX index_weekly_runtime_usages_on_namespace_id ON weekly_runtime_usages USING btree (namespace_id); + ALTER TABLE ONLY node_parameters ADD CONSTRAINT fk_rails_0d79310cfa FOREIGN KEY (node_function_id) REFERENCES node_functions(id) ON DELETE CASCADE; @@ -1402,6 +1431,9 @@ ALTER TABLE ONLY flows ALTER TABLE ONLY flow_settings ADD CONSTRAINT fk_rails_da3b2fb3c5 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; +ALTER TABLE ONLY weekly_runtime_usages + ADD CONSTRAINT fk_rails_e20b70409c FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE SET NULL; + ALTER TABLE ONLY runtimes ADD CONSTRAINT fk_rails_eeb42116cc FOREIGN KEY (namespace_id) REFERENCES namespaces(id); @@ -1419,3 +1451,6 @@ ALTER TABLE ONLY flow_type_settings ALTER TABLE ONLY node_functions ADD CONSTRAINT fk_rails_fbc91a3407 FOREIGN KEY (next_node_id) REFERENCES node_functions(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY weekly_runtime_usages + ADD CONSTRAINT fk_rails_fff245ffb4 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; From 5c2b8ba7b3b188b87643a6a2ba4d38df6be6dda6 Mon Sep 17 00:00:00 2001 From: Raphael Date: Sun, 10 May 2026 11:34:00 +0200 Subject: [PATCH 2/9] feat: wip runtime usage service --- app/services/error_code.rb | 1 + .../grpc/runtime_usage_update_service.rb | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/services/runtimes/grpc/runtime_usage_update_service.rb diff --git a/app/services/error_code.rb b/app/services/error_code.rb index 44ab5bdd..fef739d2 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -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' }, diff --git a/app/services/runtimes/grpc/runtime_usage_update_service.rb b/app/services/runtimes/grpc/runtime_usage_update_service.rb new file mode 100644 index 00000000..1db4c45e --- /dev/null +++ b/app/services/runtimes/grpc/runtime_usage_update_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Runtimes + module Grpc + class RuntimeUsageUpdaetService + include Sagittarius::Database::Transactional + include Code0::ZeroTrack::Loggable + + attr_reader :current_runtime, :usages + + def initialize(current_runtime, usages) + @current_runtime = current_runtime + @usages = usages + end + + def execute + transactional do |t| + week_start = Time.zone.today.beginning_of_week(:monday) + week_end = week_start + 6.days + + db_usage = create_or_find_by!(user: user, week_start: week_start) do |weekly_count| + weekly_count.week_end = week_end + weekly_count.value_count = 0 + end + + db_usage.with_lock do + record.value_count += amount.to_d + record.week_end ||= week_end + next if record.save + + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to runtime usage', + error_code: :invalid_runtime_usage, + details: db_usage.errors + ) + end + end + end + end + end +end From f5b2c6f0f64e7dec7c10c409c3cc65938a69cfa8 Mon Sep 17 00:00:00 2001 From: Raphael Date: Sun, 10 May 2026 18:29:34 +0200 Subject: [PATCH 3/9] feat: switched weekly interval for to daily --- ...60510081622_create_daily_runtime_usage.rb} | 7 +- db/structure.sql | 69 +++++++++---------- 2 files changed, 37 insertions(+), 39 deletions(-) rename db/migrate/{20260510081622_create_weekly_runtime_usage.rb => 20260510081622_create_daily_runtime_usage.rb} (64%) diff --git a/db/migrate/20260510081622_create_weekly_runtime_usage.rb b/db/migrate/20260510081622_create_daily_runtime_usage.rb similarity index 64% rename from db/migrate/20260510081622_create_weekly_runtime_usage.rb rename to db/migrate/20260510081622_create_daily_runtime_usage.rb index f4d01238..4a957fa6 100644 --- a/db/migrate/20260510081622_create_weekly_runtime_usage.rb +++ b/db/migrate/20260510081622_create_daily_runtime_usage.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true -class CreateWeeklyRuntimeUsage < Code0::ZeroTrack::Database::Migration[1.0] +class CreateDailyRuntimeUsage < Code0::ZeroTrack::Database::Migration[1.0] def change - create_table :weekly_runtime_usages do |t| + 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 :week_start - t.date :week_end + t.date :day t.decimal :usage t.timestamps_with_timezone diff --git a/db/structure.sql b/db/structure.sql index 6f56d6a8..b260c22d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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, + usage numeric, + 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, @@ -876,26 +895,6 @@ CREATE SEQUENCE users_id_seq ALTER SEQUENCE users_id_seq OWNED BY users.id; -CREATE TABLE weekly_runtime_usages ( - id bigint NOT NULL, - flow_id bigint, - namespace_id bigint NOT NULL, - week_start date, - week_end date, - usage numeric, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - -CREATE SEQUENCE weekly_runtime_usages_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE weekly_runtime_usages_id_seq OWNED BY weekly_runtime_usages.id; - ALTER TABLE ONLY active_storage_attachments ALTER COLUMN id SET DEFAULT nextval('active_storage_attachments_id_seq'::regclass); ALTER TABLE ONLY active_storage_blobs ALTER COLUMN id SET DEFAULT nextval('active_storage_blobs_id_seq'::regclass); @@ -908,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); @@ -978,8 +979,6 @@ ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_session ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); -ALTER TABLE ONLY weekly_runtime_usages ALTER COLUMN id SET DEFAULT nextval('weekly_runtime_usages_id_seq'::regclass); - ALTER TABLE ONLY active_storage_attachments ADD CONSTRAINT active_storage_attachments_pkey PRIMARY KEY (id); @@ -1001,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); @@ -1124,9 +1126,6 @@ ALTER TABLE ONLY user_sessions ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); -ALTER TABLE ONLY weekly_runtime_usages - ADD CONSTRAINT weekly_runtime_usages_pkey PRIMARY KEY (id); - CREATE UNIQUE INDEX idx_on_data_type_id_referenced_data_type_id_bb9b090c90 ON data_type_data_type_links USING btree (data_type_id, referenced_data_type_id); CREATE UNIQUE INDEX idx_on_flow_id_referenced_data_type_id_14b02b52f8 ON flow_data_type_links USING btree (flow_id, referenced_data_type_id); @@ -1159,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); @@ -1289,10 +1292,6 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower( CREATE UNIQUE INDEX index_users_on_totp_secret ON users USING btree (totp_secret) WHERE (totp_secret IS NOT NULL); -CREATE INDEX index_weekly_runtime_usages_on_flow_id ON weekly_runtime_usages USING btree (flow_id); - -CREATE INDEX index_weekly_runtime_usages_on_namespace_id ON weekly_runtime_usages USING btree (namespace_id); - ALTER TABLE ONLY node_parameters ADD CONSTRAINT fk_rails_0d79310cfa FOREIGN KEY (node_function_id) REFERENCES node_functions(id) ON DELETE CASCADE; @@ -1350,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); @@ -1410,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; @@ -1431,9 +1436,6 @@ ALTER TABLE ONLY flows ALTER TABLE ONLY flow_settings ADD CONSTRAINT fk_rails_da3b2fb3c5 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; -ALTER TABLE ONLY weekly_runtime_usages - ADD CONSTRAINT fk_rails_e20b70409c FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE SET NULL; - ALTER TABLE ONLY runtimes ADD CONSTRAINT fk_rails_eeb42116cc FOREIGN KEY (namespace_id) REFERENCES namespaces(id); @@ -1451,6 +1453,3 @@ ALTER TABLE ONLY flow_type_settings ALTER TABLE ONLY node_functions ADD CONSTRAINT fk_rails_fbc91a3407 FOREIGN KEY (next_node_id) REFERENCES node_functions(id) DEFERRABLE INITIALLY DEFERRED; - -ALTER TABLE ONLY weekly_runtime_usages - ADD CONSTRAINT fk_rails_fff245ffb4 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; From 0b3ae6b018f0ac28b47a619ea82f4db97b30162d Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 20:18:10 +0200 Subject: [PATCH 4/9] feat: added default value for usage --- db/migrate/20260510081622_create_daily_runtime_usage.rb | 4 ++-- db/structure.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/db/migrate/20260510081622_create_daily_runtime_usage.rb b/db/migrate/20260510081622_create_daily_runtime_usage.rb index 4a957fa6..f40fbf69 100644 --- a/db/migrate/20260510081622_create_daily_runtime_usage.rb +++ b/db/migrate/20260510081622_create_daily_runtime_usage.rb @@ -5,8 +5,8 @@ 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 - t.decimal :usage + t.date :day, null: false + t.decimal :usage, null: false, default: 0 t.timestamps_with_timezone end diff --git a/db/structure.sql b/db/structure.sql index b260c22d..b7218a8c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -125,8 +125,8 @@ CREATE TABLE daily_runtime_usages ( id bigint NOT NULL, flow_id bigint, namespace_id bigint NOT NULL, - day date, - usage numeric, + 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 ); From 4e8f318d99c1f426a275b0358fd57e4b1ab9efa5 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 20:18:56 +0200 Subject: [PATCH 5/9] feat: added runtime usage service --- app/models/daily_runtime_usage.rb | 9 ++ app/models/flow.rb | 1 + app/models/namespace.rb | 1 + .../grpc/runtime_usage_update_service.rb | 104 ++++++++++++++--- spec/factories/daily_runtime_usages.rb | 10 ++ .../grpc/runtime_usage_update_service_spec.rb | 105 ++++++++++++++++++ 6 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 app/models/daily_runtime_usage.rb create mode 100644 spec/factories/daily_runtime_usages.rb create mode 100644 spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb diff --git a/app/models/daily_runtime_usage.rb b/app/models/daily_runtime_usage.rb new file mode 100644 index 00000000..a0917179 --- /dev/null +++ b/app/models/daily_runtime_usage.rb @@ -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 diff --git a/app/models/flow.rb b/app/models/flow.rb index fb2ec766..14d81e42 100644 --- a/app/models/flow.rb +++ b/app/models/flow.rb @@ -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 diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 633f7514..72aeb8dc 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -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 diff --git a/app/services/runtimes/grpc/runtime_usage_update_service.rb b/app/services/runtimes/grpc/runtime_usage_update_service.rb index 1db4c45e..65705964 100644 --- a/app/services/runtimes/grpc/runtime_usage_update_service.rb +++ b/app/services/runtimes/grpc/runtime_usage_update_service.rb @@ -2,40 +2,110 @@ module Runtimes module Grpc - class RuntimeUsageUpdaetService + class RuntimeUsageUpdateService include Sagittarius::Database::Transactional include Code0::ZeroTrack::Loggable attr_reader :current_runtime, :usages - def initialize(current_runtime, usages) + def initialize(current_runtime:, usages:) @current_runtime = current_runtime @usages = usages end def execute transactional do |t| - week_start = Time.zone.today.beginning_of_week(:monday) - week_end = week_start + 6.days + updated_usages = [] - db_usage = create_or_find_by!(user: user, week_start: week_start) do |weekly_count| - weekly_count.week_end = week_end - weekly_count.value_count = 0 + Array.wrap(usages).each do |usage| + result = update_usage(usage) + t.rollback_and_return! result if result.error? + + updated_usages << result.payload end - db_usage.with_lock do - record.value_count += amount.to_d - record.week_end ||= week_end - next if record.save + ServiceResponse.success(message: 'Updated runtime usage', payload: updated_usages) + end + end - t.rollback_and_return! ServiceResponse.error( - message: 'Failed to runtime usage', - error_code: :invalid_runtime_usage, - details: db_usage.errors - ) - 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? + + unless runtime_assigned_to_project?(flow.project) + return ServiceResponse.error(message: 'Runtime is not assigned to the project', + error_code: :runtime_not_assigned) + end + + 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.create_or_find_by!( + namespace: flow.project.namespace, + flow: flow, + day: day + ) + + db_usage.with_lock do + db_usage.usage += amount + return ServiceResponse.success(payload: db_usage) if db_usage.save + + invalid_usage_error(db_usage.errors) + end + rescue ActiveRecord::RecordInvalid => e + invalid_usage_error(e.record.errors) + rescue ArgumentError + invalid_usage_error('Usage interval must be a valid date') + end + + def runtime_assigned_to_project?(project) + current_runtime.project_assignments.compatible.exists?(namespace_project: project) + end + + def usage_day(usage) + value = usage_attribute(usage, :day, :date, :interval) + + 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, :usage, :amount, :count) + return if value.nil? + + BigDecimal(value.to_s) + rescue ArgumentError + nil + 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 diff --git a/spec/factories/daily_runtime_usages.rb b/spec/factories/daily_runtime_usages.rb new file mode 100644 index 00000000..96422ec8 --- /dev/null +++ b/spec/factories/daily_runtime_usages.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :daily_runtime_usage do + flow + namespace { flow.project.namespace } + day { Time.zone.today } + usage { 1 } + end +end diff --git a/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb b/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb new file mode 100644 index 00000000..72f87598 --- /dev/null +++ b/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Runtimes::Grpc::RuntimeUsageUpdateService do + subject(:service_response) { described_class.new(current_runtime: runtime, usages: usages).execute } + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:runtime) { create(:runtime, namespace: namespace) } + let(:flow) { create(:flow, project: project) } + let(:day) { Date.new(2026, 5, 10) } + let(:usages) { [{ flow_id: flow.id, interval: day, usage: 3 }] } + + before do + create(:namespace_project_runtime_assignment, + runtime: runtime, + namespace_project: project, + compatible: true) + end + + it 'creates a daily runtime usage for the flow namespace' do + expect(service_response).to be_success + + usage = DailyRuntimeUsage.last + expect(usage.flow).to eq(flow) + expect(usage.namespace).to eq(namespace) + expect(usage.day).to eq(day) + expect(usage.usage).to eq(3) + end + + context 'when usage already exists for the interval' do + before do + create(:daily_runtime_usage, flow: flow, namespace: namespace, day: day, usage: 5) + end + + it 'increments the existing usage' do + expect { service_response }.not_to change { DailyRuntimeUsage.count } + expect(service_response).to be_success + expect(DailyRuntimeUsage.last.usage).to eq(8) + end + end + + context 'with multiple usages' do + let(:second_flow) { create(:flow, project: project) } + let(:usages) do + [ + { flow_id: flow.id, interval: day, usage: 3 }, + { flow_id: second_flow.id, interval: day, usage: 4 } + ] + end + + it 'records each flow usage' do + expect(service_response).to be_success + expect(DailyRuntimeUsage.where(day: day).pluck(:flow_id, :usage)).to contain_exactly( + [flow.id, 3.to_d], + [second_flow.id, 4.to_d] + ) + end + end + + context 'when the flow is deleted later' do + it 'keeps the usage connected to the namespace' do + expect(service_response).to be_success + + usage = DailyRuntimeUsage.last + flow.delete + + expect(usage.reload.flow).to be_nil + expect(usage.namespace).to eq(namespace) + end + end + + context 'when the runtime is not assigned to the project' do + before do + NamespaceProjectRuntimeAssignment.delete_all + end + + it 'returns an error' do + expect(service_response).to be_error + expect(service_response.payload[:error_code]).to eq(:runtime_not_assigned) + expect(DailyRuntimeUsage.count).to eq(0) + end + end + + context 'when the usage amount is invalid' do + let(:usages) { [{ flow_id: flow.id, interval: day, usage: -1 }] } + + it 'returns an error' do + expect(service_response).to be_error + expect(service_response.payload[:error_code]).to eq(:invalid_runtime_usage) + expect(DailyRuntimeUsage.count).to eq(0) + end + end + + context 'when the interval is invalid' do + let(:usages) { [{ flow_id: flow.id, interval: 'not-a-date', usage: 1 }] } + + it 'returns an error' do + expect(service_response).to be_error + expect(service_response.payload[:error_code]).to eq(:invalid_runtime_usage) + expect(DailyRuntimeUsage.count).to eq(0) + end + end +end From 36c0a9cdaba8a6a0f62b0a0ac043b604c2e14fa9 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 20:26:20 +0200 Subject: [PATCH 6/9] feat: added runtime usage grpc handler --- app/grpc/runtime_usage_handler.rb | 14 ++++++++++++++ .../runtimes/grpc/runtime_usage_update_service.rb | 5 ++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 app/grpc/runtime_usage_handler.rb diff --git a/app/grpc/runtime_usage_handler.rb b/app/grpc/runtime_usage_handler.rb new file mode 100644 index 00000000..949159a5 --- /dev/null +++ b/app/grpc/runtime_usage_handler.rb @@ -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(request).execute + + logger.debug("RuntimeUsageHandler#update response: #{response.inspect}") + + Tucana::Sagittarius::RuntimeUsageUpdateResponse.new(success: response.success?) + end +end diff --git a/app/services/runtimes/grpc/runtime_usage_update_service.rb b/app/services/runtimes/grpc/runtime_usage_update_service.rb index 65705964..acec3f18 100644 --- a/app/services/runtimes/grpc/runtime_usage_update_service.rb +++ b/app/services/runtimes/grpc/runtime_usage_update_service.rb @@ -6,10 +6,9 @@ class RuntimeUsageUpdateService include Sagittarius::Database::Transactional include Code0::ZeroTrack::Loggable - attr_reader :current_runtime, :usages + attr_reader :usages - def initialize(current_runtime:, usages:) - @current_runtime = current_runtime + def initialize(usages:) @usages = usages end From 85de3856ce6bd637050f7e367bb86a8e0cd5295d Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 12 May 2026 20:02:52 +0200 Subject: [PATCH 7/9] feat: ipdated specs --- app/grpc/runtime_usage_handler.rb | 4 +- .../grpc/runtime_usage_update_service.rb | 30 ++++----- .../sagittarius/runtime_usage_service_spec.rb | 65 +++++++++++++++++++ .../grpc/runtime_usage_update_service_spec.rb | 37 ++++------- 4 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 spec/requests/grpc/sagittarius/runtime_usage_service_spec.rb diff --git a/app/grpc/runtime_usage_handler.rb b/app/grpc/runtime_usage_handler.rb index 949159a5..19792df0 100644 --- a/app/grpc/runtime_usage_handler.rb +++ b/app/grpc/runtime_usage_handler.rb @@ -5,10 +5,10 @@ class RuntimeUsageHandler < Tucana::Sagittarius::RuntimeUsageService::Service include GrpcHandler def update(request, _call) - response = Runtimes::Grpc::RuntimeUsageUpdateService.new(request).execute + response = Runtimes::Grpc::RuntimeUsageUpdateService.new(usages: request.runtime_usage).execute logger.debug("RuntimeUsageHandler#update response: #{response.inspect}") - Tucana::Sagittarius::RuntimeUsageUpdateResponse.new(success: response.success?) + Tucana::Sagittarius::RuntimeUsageResponse.new(success: response.success?) end end diff --git a/app/services/runtimes/grpc/runtime_usage_update_service.rb b/app/services/runtimes/grpc/runtime_usage_update_service.rb index acec3f18..3ec9c03d 100644 --- a/app/services/runtimes/grpc/runtime_usage_update_service.rb +++ b/app/services/runtimes/grpc/runtime_usage_update_service.rb @@ -33,39 +33,30 @@ 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? - unless runtime_assigned_to_project?(flow.project) - return ServiceResponse.error(message: 'Runtime is not assigned to the project', - error_code: :runtime_not_assigned) - end - 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.create_or_find_by!( + db_usage = DailyRuntimeUsage.find_or_initialize_by( namespace: flow.project.namespace, flow: flow, day: day ) - db_usage.with_lock do - db_usage.usage += amount - return ServiceResponse.success(payload: db_usage) if db_usage.save + return increment_usage(db_usage, amount) unless db_usage.persisted? - invalid_usage_error(db_usage.errors) - end + db_usage.with_lock { increment_usage(db_usage, amount) } rescue ActiveRecord::RecordInvalid => e invalid_usage_error(e.record.errors) + rescue ActiveRecord::RecordNotUnique + retry rescue ArgumentError invalid_usage_error('Usage interval must be a valid date') end - def runtime_assigned_to_project?(project) - current_runtime.project_assignments.compatible.exists?(namespace_project: project) - end - def usage_day(usage) value = usage_attribute(usage, :day, :date, :interval) + return Time.zone.today if value.nil? case value when Date @@ -80,7 +71,7 @@ def usage_day(usage) end def usage_amount(usage) - value = usage_attribute(usage, :usage, :amount, :count) + value = usage_attribute(usage, :duration, :usage, :amount, :count) return if value.nil? BigDecimal(value.to_s) @@ -88,6 +79,13 @@ def usage_amount(usage) 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) diff --git a/spec/requests/grpc/sagittarius/runtime_usage_service_spec.rb b/spec/requests/grpc/sagittarius/runtime_usage_service_spec.rb new file mode 100644 index 00000000..19c7224d --- /dev/null +++ b/spec/requests/grpc/sagittarius/runtime_usage_service_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'sagittarius.RuntimeUsageService', :need_grpc_server do + include GrpcHelpers + + let(:stub) { create_stub Tucana::Sagittarius::RuntimeUsageService } + let(:runtime) { create(:runtime) } + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:flow) { create(:flow, project: project) } + + describe 'Update' do + let(:runtime_usage) do + [ + Tucana::Shared::RuntimeUsage.new( + flow_id: flow.id, + duration: 3 + ) + ] + end + + let(:message) do + Tucana::Sagittarius::RuntimeUsageRequest.new(runtime_usage: runtime_usage) + end + + it 'creates a daily runtime usage' do + expect(stub.update(message, authorization(runtime)).success).to be(true) + + usage = DailyRuntimeUsage.last + expect(usage.flow).to eq(flow) + expect(usage.namespace).to eq(namespace) + expect(usage.day).to eq(Time.zone.today) + expect(usage.usage).to eq(3) + end + + context 'when usage already exists' do + before do + create(:daily_runtime_usage, flow: flow, namespace: namespace, day: Time.zone.today, usage: 5) + end + + it 'increments the existing usage' do + expect { stub.update(message, authorization(runtime)) }.not_to change { DailyRuntimeUsage.count } + expect(DailyRuntimeUsage.last.usage).to eq(8) + end + end + + context 'when the flow does not exist' do + let(:runtime_usage) do + [ + Tucana::Shared::RuntimeUsage.new( + flow_id: Flow.maximum(:id).to_i + 1, + duration: 3 + ) + ] + end + + it 'returns a failure response' do + expect(stub.update(message, authorization(runtime)).success).to be(false) + expect(DailyRuntimeUsage.count).to eq(0) + end + end + end +end diff --git a/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb b/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb index 72f87598..9b34b615 100644 --- a/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb +++ b/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb @@ -3,21 +3,13 @@ require 'rails_helper' RSpec.describe Runtimes::Grpc::RuntimeUsageUpdateService do - subject(:service_response) { described_class.new(current_runtime: runtime, usages: usages).execute } + subject(:service_response) { described_class.new(usages: usages).execute } let(:namespace) { create(:namespace) } let(:project) { create(:namespace_project, namespace: namespace) } - let(:runtime) { create(:runtime, namespace: namespace) } let(:flow) { create(:flow, project: project) } let(:day) { Date.new(2026, 5, 10) } - let(:usages) { [{ flow_id: flow.id, interval: day, usage: 3 }] } - - before do - create(:namespace_project_runtime_assignment, - runtime: runtime, - namespace_project: project, - compatible: true) - end + let(:usages) { [{ flow_id: flow.id, interval: day, duration: 3 }] } it 'creates a daily runtime usage for the flow namespace' do expect(service_response).to be_success @@ -46,7 +38,7 @@ let(:usages) do [ { flow_id: flow.id, interval: day, usage: 3 }, - { flow_id: second_flow.id, interval: day, usage: 4 } + { flow_id: second_flow.id, interval: day, duration: 4 } ] end @@ -71,20 +63,18 @@ end end - context 'when the runtime is not assigned to the project' do - before do - NamespaceProjectRuntimeAssignment.delete_all - end + context 'when the usage amount is invalid' do + let(:usages) { [{ flow_id: flow.id, interval: day, duration: -1 }] } it 'returns an error' do expect(service_response).to be_error - expect(service_response.payload[:error_code]).to eq(:runtime_not_assigned) + expect(service_response.payload[:error_code]).to eq(:invalid_runtime_usage) expect(DailyRuntimeUsage.count).to eq(0) end end - context 'when the usage amount is invalid' do - let(:usages) { [{ flow_id: flow.id, interval: day, usage: -1 }] } + context 'when the interval is invalid' do + let(:usages) { [{ flow_id: flow.id, interval: 'not-a-date', duration: 1 }] } it 'returns an error' do expect(service_response).to be_error @@ -93,13 +83,12 @@ end end - context 'when the interval is invalid' do - let(:usages) { [{ flow_id: flow.id, interval: 'not-a-date', usage: 1 }] } + context 'when no interval is given' do + let(:usages) { [{ flow_id: flow.id, duration: 1 }] } - it 'returns an error' do - expect(service_response).to be_error - expect(service_response.payload[:error_code]).to eq(:invalid_runtime_usage) - expect(DailyRuntimeUsage.count).to eq(0) + it 'uses the current day' do + expect(service_response).to be_success + expect(DailyRuntimeUsage.last.day).to eq(Time.zone.today) end end end From d304c36d3a1435fc7f33e03ac1866348c53c99db Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 12 May 2026 20:44:22 +0200 Subject: [PATCH 8/9] feat: exposed daily runtime usage to graphql interface --- app/graphql/types/daily_runtime_usage_type.rb | 18 +++ app/graphql/types/date_type.rb | 23 ++++ app/graphql/types/flow_type.rb | 18 +++ app/graphql/types/namespace_type.rb | 21 +++ docs/graphql/enum/errorcodeenum.md | 1 + docs/graphql/object/dailyruntimeusage.md | 17 +++ .../object/dailyruntimeusageconnection.md | 14 ++ docs/graphql/object/dailyruntimeusageedge.md | 12 ++ docs/graphql/object/flow.md | 17 +++ docs/graphql/object/namespace.md | 16 +++ docs/graphql/scalar/dailyruntimeusageid.md | 5 + docs/graphql/scalar/date.md | 7 + docs/graphql/scalar/float.md | 5 + .../types/daily_runtime_usage_type_spec.rb | 21 +++ spec/graphql/types/date_type_spec.rb | 13 ++ spec/graphql/types/flow_type_spec.rb | 29 ++++ spec/graphql/types/namespace_type_spec.rb | 1 + .../query/flow_runtime_usages_query_spec.rb | 103 ++++++++++++++ .../namespace_runtime_usages_query_spec.rb | 126 ++++++++++++++++++ 19 files changed, 467 insertions(+) create mode 100644 app/graphql/types/daily_runtime_usage_type.rb create mode 100644 app/graphql/types/date_type.rb create mode 100644 docs/graphql/object/dailyruntimeusage.md create mode 100644 docs/graphql/object/dailyruntimeusageconnection.md create mode 100644 docs/graphql/object/dailyruntimeusageedge.md create mode 100644 docs/graphql/scalar/dailyruntimeusageid.md create mode 100644 docs/graphql/scalar/date.md create mode 100644 docs/graphql/scalar/float.md create mode 100644 spec/graphql/types/daily_runtime_usage_type_spec.rb create mode 100644 spec/graphql/types/date_type_spec.rb create mode 100644 spec/graphql/types/flow_type_spec.rb create mode 100644 spec/requests/graphql/query/flow_runtime_usages_query_spec.rb create mode 100644 spec/requests/graphql/query/namespace_runtime_usages_query_spec.rb diff --git a/app/graphql/types/daily_runtime_usage_type.rb b/app/graphql/types/daily_runtime_usage_type.rb new file mode 100644 index 00000000..14ccbb2e --- /dev/null +++ b/app/graphql/types/daily_runtime_usage_type.rb @@ -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 diff --git a/app/graphql/types/date_type.rb b/app/graphql/types/date_type.rb new file mode 100644 index 00000000..9da1eebc --- /dev/null +++ b/app/graphql/types/date_type.rb @@ -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 diff --git a/app/graphql/types/flow_type.rb b/app/graphql/types/flow_type.rb index a8841da0..758d2711 100644 --- a/app/graphql/types/flow_type.rb +++ b/app/graphql/types/flow_type.rb @@ -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, @@ -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 diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 2d2fbfb1..5c2871ce 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -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' @@ -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 diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index a471a6d5..df21ae64 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -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 | diff --git a/docs/graphql/object/dailyruntimeusage.md b/docs/graphql/object/dailyruntimeusage.md new file mode 100644 index 00000000..24325c16 --- /dev/null +++ b/docs/graphql/object/dailyruntimeusage.md @@ -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 | diff --git a/docs/graphql/object/dailyruntimeusageconnection.md b/docs/graphql/object/dailyruntimeusageconnection.md new file mode 100644 index 00000000..468ab206 --- /dev/null +++ b/docs/graphql/object/dailyruntimeusageconnection.md @@ -0,0 +1,14 @@ +--- +title: DailyRuntimeUsageConnection +--- + +The connection type for DailyRuntimeUsage. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `count` | [`Int!`](../scalar/int.md) | Total count of collection. | +| `edges` | [`[DailyRuntimeUsageEdge]`](../object/dailyruntimeusageedge.md) | A list of edges. | +| `nodes` | [`[DailyRuntimeUsage]`](../object/dailyruntimeusage.md) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](../object/pageinfo.md) | Information to aid in pagination. | diff --git a/docs/graphql/object/dailyruntimeusageedge.md b/docs/graphql/object/dailyruntimeusageedge.md new file mode 100644 index 00000000..ca788627 --- /dev/null +++ b/docs/graphql/object/dailyruntimeusageedge.md @@ -0,0 +1,12 @@ +--- +title: DailyRuntimeUsageEdge +--- + +An edge in a connection. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `cursor` | [`String!`](../scalar/string.md) | A cursor for use in pagination. | +| `node` | [`DailyRuntimeUsage`](../object/dailyruntimeusage.md) | The item at the end of the edge. | diff --git a/docs/graphql/object/flow.md b/docs/graphql/object/flow.md index 493dcb3e..52056da8 100644 --- a/docs/graphql/object/flow.md +++ b/docs/graphql/object/flow.md @@ -22,3 +22,20 @@ Represents a flow | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this Flow was last updated | | `userAbilities` | [`FlowUserAbilities!`](../object/flowuserabilities.md) | Abilities for the current user on this Flow | | `validationStatus` | [`FlowValidationStatus!`](../enum/flowvalidationstatus.md) | The validation status of the flow | + +## Fields with arguments + +### dailyRuntimeUsages + +Daily runtime usage entries for this flow + +Returns [`DailyRuntimeUsageConnection!`](../object/dailyruntimeusageconnection.md). + +| Name | Type | Description | +|------|------|-------------| +| `after` | [`String`](../scalar/string.md) | Returns the elements in the list that come after the specified cursor. | +| `before` | [`String`](../scalar/string.md) | Returns the elements in the list that come before the specified cursor. | +| `first` | [`Int`](../scalar/int.md) | Returns the first _n_ elements from the list. | +| `from` | [`Date`](../scalar/date.md) | Only return usage entries on or after this day | +| `last` | [`Int`](../scalar/int.md) | Returns the last _n_ elements from the list. | +| `to` | [`Date`](../scalar/date.md) | Only return usage entries on or before this day | diff --git a/docs/graphql/object/namespace.md b/docs/graphql/object/namespace.md index be4540f1..d24cb1e7 100644 --- a/docs/graphql/object/namespace.md +++ b/docs/graphql/object/namespace.md @@ -22,6 +22,22 @@ Represents a Namespace ## Fields with arguments +### dailyRuntimeUsages + +Daily runtime usage entries for this namespace + +Returns [`DailyRuntimeUsageConnection!`](../object/dailyruntimeusageconnection.md). + +| Name | Type | Description | +|------|------|-------------| +| `after` | [`String`](../scalar/string.md) | Returns the elements in the list that come after the specified cursor. | +| `before` | [`String`](../scalar/string.md) | Returns the elements in the list that come before the specified cursor. | +| `first` | [`Int`](../scalar/int.md) | Returns the first _n_ elements from the list. | +| `flowId` | [`FlowID`](../scalar/flowid.md) | Only return usage entries for this flow | +| `from` | [`Date`](../scalar/date.md) | Only return usage entries on or after this day | +| `last` | [`Int`](../scalar/int.md) | Returns the last _n_ elements from the list. | +| `to` | [`Date`](../scalar/date.md) | Only return usage entries on or before this day | + ### project Query a project by its id diff --git a/docs/graphql/scalar/dailyruntimeusageid.md b/docs/graphql/scalar/dailyruntimeusageid.md new file mode 100644 index 00000000..6d3747a9 --- /dev/null +++ b/docs/graphql/scalar/dailyruntimeusageid.md @@ -0,0 +1,5 @@ +--- +title: DailyRuntimeUsageID +--- + +A unique identifier for all DailyRuntimeUsage entities of the application diff --git a/docs/graphql/scalar/date.md b/docs/graphql/scalar/date.md new file mode 100644 index 00000000..3c01c9e4 --- /dev/null +++ b/docs/graphql/scalar/date.md @@ -0,0 +1,7 @@ +--- +title: Date +--- + +Date represented in ISO 8601. + +For example: "2026-05-12". diff --git a/docs/graphql/scalar/float.md b/docs/graphql/scalar/float.md new file mode 100644 index 00000000..aa9ffb6c --- /dev/null +++ b/docs/graphql/scalar/float.md @@ -0,0 +1,5 @@ +--- +title: Float +--- + +Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). diff --git a/spec/graphql/types/daily_runtime_usage_type_spec.rb b/spec/graphql/types/daily_runtime_usage_type_spec.rb new file mode 100644 index 00000000..7dd4ac5d --- /dev/null +++ b/spec/graphql/types/daily_runtime_usage_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['DailyRuntimeUsage'] do + let(:fields) do + %w[ + id + day + flow + namespace + usage + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('DailyRuntimeUsage') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_namespace) } +end diff --git a/spec/graphql/types/date_type_spec.rb b/spec/graphql/types/date_type_spec.rb new file mode 100644 index 00000000..0d22e225 --- /dev/null +++ b/spec/graphql/types/date_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::DateType do + it 'coerces input from ISO 8601 dates' do + expect(described_class.coerce_input('2026-05-12', nil)).to eq(Date.new(2026, 5, 12)) + end + + it 'coerces results to ISO 8601 dates' do + expect(described_class.coerce_result(Date.new(2026, 5, 12), nil)).to eq('2026-05-12') + end +end diff --git a/spec/graphql/types/flow_type_spec.rb b/spec/graphql/types/flow_type_spec.rb new file mode 100644 index 00000000..cf9f9ca9 --- /dev/null +++ b/spec/graphql/types/flow_type_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['Flow'] do + let(:fields) do + %w[ + id + dailyRuntimeUsages + name + disabledReason + validationStatus + project + settings + signature + startingNodeId + type + nodes + linkedDataTypes + userAbilities + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('Flow') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_flow) } +end diff --git a/spec/graphql/types/namespace_type_spec.rb b/spec/graphql/types/namespace_type_spec.rb index c36d2fe7..79b16d8d 100644 --- a/spec/graphql/types/namespace_type_spec.rb +++ b/spec/graphql/types/namespace_type_spec.rb @@ -10,6 +10,7 @@ members roles runtimes + dailyRuntimeUsages project projects userAbilities diff --git a/spec/requests/graphql/query/flow_runtime_usages_query_spec.rb b/spec/requests/graphql/query/flow_runtime_usages_query_spec.rb new file mode 100644 index 00000000..ad7fe26b --- /dev/null +++ b/spec/requests/graphql/query/flow_runtime_usages_query_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'flow dailyRuntimeUsages Query' do + include GraphqlHelpers + + let(:query) do + <<~QUERY + query($namespaceId: NamespaceID!, $projectId: NamespaceProjectID!, $flowId: FlowID!, $from: Date, $to: Date) { + namespace(id: $namespaceId) { + project(id: $projectId) { + flow(id: $flowId) { + dailyRuntimeUsages(from: $from, to: $to) { + nodes { + id + day + usage + namespace { id } + } + } + } + } + } + } + QUERY + end + + let(:current_user) do + create(:user).tap do |user| + member = create(:namespace_member, namespace: namespace, user: user) + role = create(:namespace_role, namespace: namespace) + create(:namespace_member_role, member: member, role: role) + create(:namespace_role_project_assignment, role: role, project: project) + create(:namespace_role_ability, namespace_role: role, ability: :read_namespace_project) + end + end + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:flow) { create(:flow, project: project) } + let(:other_flow) { create(:flow, project: project) } + let!(:runtime_usage) do + create(:daily_runtime_usage, namespace: namespace, flow: flow, day: Date.new(2026, 5, 10), usage: 5) + end + let!(:later_runtime_usage) do + create(:daily_runtime_usage, namespace: namespace, flow: flow, day: Date.new(2026, 5, 11), usage: 8) + end + let!(:other_flow_usage) do + create(:daily_runtime_usage, namespace: namespace, flow: other_flow, day: Date.new(2026, 5, 11), usage: 13) + end + let(:variables) do + { + namespaceId: namespace.to_global_id.to_s, + projectId: project.to_global_id.to_s, + flowId: flow.to_global_id.to_s, + } + end + + before do + post_graphql query, variables: variables, current_user: current_user + end + + it 'returns runtime usages for the flow' do + usage_nodes = graphql_data_at(:namespace, :project, :flow, :daily_runtime_usages, :nodes) + + expect(usage_nodes).to contain_exactly( + a_hash_including( + 'id' => runtime_usage.to_global_id.to_s, + 'day' => '2026-05-10', + 'usage' => 5.0, + 'namespace' => { 'id' => namespace.to_global_id.to_s } + ), + a_hash_including( + 'id' => later_runtime_usage.to_global_id.to_s, + 'day' => '2026-05-11', + 'usage' => 8.0, + 'namespace' => { 'id' => namespace.to_global_id.to_s } + ) + ) + expect(usage_nodes.pluck('id')).not_to include(other_flow_usage.to_global_id.to_s) + end + + context 'with date filtering' do + let(:variables) do + { + namespaceId: namespace.to_global_id.to_s, + projectId: project.to_global_id.to_s, + flowId: flow.to_global_id.to_s, + from: '2026-05-11', + to: '2026-05-11', + } + end + + it 'returns only usage in the requested date range' do + usage_ids = graphql_data_at(:namespace, :project, :flow, :daily_runtime_usages, :nodes).pluck('id') + + expect(usage_ids).to contain_exactly( + later_runtime_usage.to_global_id.to_s + ) + end + end +end diff --git a/spec/requests/graphql/query/namespace_runtime_usages_query_spec.rb b/spec/requests/graphql/query/namespace_runtime_usages_query_spec.rb new file mode 100644 index 00000000..10d1a287 --- /dev/null +++ b/spec/requests/graphql/query/namespace_runtime_usages_query_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'namespace dailyRuntimeUsages Query' do + include GraphqlHelpers + + let(:query) do + <<~QUERY + query($namespaceId: NamespaceID!, $flowId: FlowID, $from: Date, $to: Date) { + namespace(id: $namespaceId) { + dailyRuntimeUsages(flowId: $flowId, from: $from, to: $to) { + nodes { + id + day + usage + flow { id } + namespace { id } + } + } + } + } + QUERY + end + + let(:current_user) do + create(:user).tap do |user| + member = create(:namespace_member, namespace: namespace, user: user) + role = create(:namespace_role, namespace: namespace) + create(:namespace_member_role, member: member, role: role) + create(:namespace_role_project_assignment, role: role, project: project) + create(:namespace_role_ability, namespace_role: role, ability: :read_namespace_project) + end + end + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:flow) { create(:flow, project: project) } + let(:other_flow) { create(:flow, project: project) } + let!(:runtime_usage) do + create(:daily_runtime_usage, namespace: namespace, flow: flow, day: Date.new(2026, 5, 10), usage: 5) + end + let!(:other_runtime_usage) do + create(:daily_runtime_usage, namespace: namespace, flow: other_flow, day: Date.new(2026, 5, 11), usage: 8) + end + let(:variables) { { namespaceId: namespace.to_global_id.to_s } } + + before do + post_graphql query, variables: variables, current_user: current_user + end + + it 'returns runtime usages for the namespace' do + expect(graphql_data_at(:namespace, :daily_runtime_usages, :nodes)).to contain_exactly( + a_hash_including( + 'id' => runtime_usage.to_global_id.to_s, + 'day' => '2026-05-10', + 'usage' => 5.0, + 'flow' => { 'id' => flow.to_global_id.to_s }, + 'namespace' => { 'id' => namespace.to_global_id.to_s } + ), + a_hash_including( + 'id' => other_runtime_usage.to_global_id.to_s, + 'day' => '2026-05-11', + 'usage' => 8.0, + 'flow' => { 'id' => other_flow.to_global_id.to_s }, + 'namespace' => { 'id' => namespace.to_global_id.to_s } + ) + ) + end + + context 'with flow filtering' do + let(:variables) do + { + namespaceId: namespace.to_global_id.to_s, + flowId: flow.to_global_id.to_s, + } + end + + it 'returns only usage for the requested flow' do + expect(graphql_data_at(:namespace, :daily_runtime_usages, :nodes).pluck('id')).to contain_exactly( + runtime_usage.to_global_id.to_s + ) + end + end + + context 'with date filtering' do + let(:variables) do + { + namespaceId: namespace.to_global_id.to_s, + from: '2026-05-11', + to: '2026-05-11', + } + end + + it 'returns only usage in the requested date range' do + expect(graphql_data_at(:namespace, :daily_runtime_usages, :nodes).pluck('id')).to contain_exactly( + other_runtime_usage.to_global_id.to_s + ) + end + end + + context 'when the flow was deleted' do + before do + runtime_usage.flow.delete + post_graphql query, variables: variables, current_user: current_user + end + + it 'keeps the usage visible through the namespace with nil flow' do + usage = graphql_data_at(:namespace, :daily_runtime_usages, :nodes).find do |node| + node['id'] == runtime_usage.to_global_id.to_s + end + + expect(usage['flow']).to be_nil + expect(usage['namespace']).to eq({ 'id' => namespace.to_global_id.to_s }) + end + end + + context 'when user is not a namespace member' do + let(:current_user) { create(:user) } + + it 'returns nil for the namespace' do + expect(graphql_data_at(:namespace)).to be_nil + expect_graphql_errors_to_be_empty + end + end +end From 9c2d4211617f609672bb098b82a132982f85c701 Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 12 May 2026 20:50:38 +0200 Subject: [PATCH 9/9] fix: correct identification for flow id --- app/graphql/types/namespace_project_type.rb | 2 +- .../cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/graphql/types/namespace_project_type.rb b/app/graphql/types/namespace_project_type.rb index c03de707..54de7ceb 100644 --- a/app/graphql/types/namespace_project_type.rb +++ b/app/graphql/types/namespace_project_type.rb @@ -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 diff --git a/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb b/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb index 09a22905..10b5bc19 100644 --- a/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb +++ b/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb @@ -8,6 +8,7 @@ id parent members + dailyRuntimeUsages roles runtimes project