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_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/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/app/grpc/runtime_usage_handler.rb b/app/grpc/runtime_usage_handler.rb new file mode 100644 index 00000000..19792df0 --- /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(usages: request.runtime_usage).execute + + logger.debug("RuntimeUsageHandler#update response: #{response.inspect}") + + Tucana::Sagittarius::RuntimeUsageResponse.new(success: response.success?) + end +end 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/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..3ec9c03d --- /dev/null +++ b/app/services/runtimes/grpc/runtime_usage_update_service.rb @@ -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 + ) + + 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 + 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 diff --git a/db/migrate/20260510081622_create_daily_runtime_usage.rb b/db/migrate/20260510081622_create_daily_runtime_usage.rb new file mode 100644 index 00000000..f40fbf69 --- /dev/null +++ b/db/migrate/20260510081622_create_daily_runtime_usage.rb @@ -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 + 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..b7218a8c 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 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, @@ -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); @@ -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); @@ -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); @@ -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); @@ -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; 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/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 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/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 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 new file mode 100644 index 00000000..9b34b615 --- /dev/null +++ b/spec/services/runtimes/grpc/runtime_usage_update_service_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Runtimes::Grpc::RuntimeUsageUpdateService do + subject(:service_response) { described_class.new(usages: usages).execute } + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:flow) { create(:flow, project: project) } + let(:day) { Date.new(2026, 5, 10) } + 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 + + 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, duration: 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 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(: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', 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) + end + end + + context 'when no interval is given' do + let(:usages) { [{ flow_id: flow.id, duration: 1 }] } + + it 'uses the current day' do + expect(service_response).to be_success + expect(DailyRuntimeUsage.last.day).to eq(Time.zone.today) + end + end +end