Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions deployment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { deployTokens } from './services/tokens';
import { deployUsage } from './services/usage';
import { deployUsageIngestor } from './services/usage-ingestor';
import { deployWebhooks } from './services/webhooks';
import { deployWorkflows } from './services/workflows';
import { configureZendesk } from './services/zendesk';
import { optimizeAzureCluster } from './utils/azure-helpers';
import { isDefined } from './utils/helpers';
Expand Down Expand Up @@ -150,6 +151,16 @@ const emails = deployEmails({
observability,
});

deployWorkflows({
image: docker.factory.getImageId('workflows', imagesTag),
docker,
environment,
postgres,
observability,
sentry,
heartbeat: heartbeatsConfig.get('workflows'),
});

const commerce = deployCommerce({
image: docker.factory.getImageId('commerce', imagesTag),
docker,
Expand Down
1 change: 0 additions & 1 deletion deployment/services/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export function deployCommerce({
env: {
...environment.envVars,
SENTRY: sentry.enabled ? '1' : '0',
EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service),
WEB_APP_URL: `https://${environment.appDns}/`,
OPENTELEMETRY_TRACE_USAGE_REQUESTS: observability.enabledForUsageService ? '1' : '',
OPENTELEMETRY_COLLECTOR_ENDPOINT:
Expand Down
2 changes: 0 additions & 2 deletions deployment/services/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,8 @@ export function deployGraphQL({
REQUEST_LOGGING: '1', // disabled
COMMERCE_ENDPOINT: serviceLocalEndpoint(commerce.service),
TOKENS_ENDPOINT: serviceLocalEndpoint(tokens.service),
WEBHOOKS_ENDPOINT: serviceLocalEndpoint(webhooks.service),
SCHEMA_ENDPOINT: serviceLocalEndpoint(schema.service),
SCHEMA_POLICY_ENDPOINT: serviceLocalEndpoint(schemaPolicy.service),
EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service),
WEB_APP_URL: `https://${environment.appDns}`,
GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`,
CDN_CF: '1',
Expand Down
78 changes: 78 additions & 0 deletions deployment/services/workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as pulumi from '@pulumi/pulumi';
import { ServiceSecret } from '../utils/secrets';
import { ServiceDeployment } from '../utils/service-deployment';
import { Docker } from './docker';
import { Environment } from './environment';
import { Observability } from './observability';
import { Postgres } from './postgres';
import { Sentry } from './sentry';

class PostmarkSecret extends ServiceSecret<{
token: pulumi.Output<string> | string;
from: string;
messageStream: string;
}> {}

export function deployWorkflows({
environment,
heartbeat,
image,
docker,
sentry,
postgres,
observability,
}: {
postgres: Postgres;
observability: Observability;
environment: Environment;
image: string;
docker: Docker;
heartbeat?: string;
sentry: Sentry;
}) {
const emailConfig = new pulumi.Config('email');
const postmarkSecret = new PostmarkSecret('postmark', {
token: emailConfig.requireSecret('token'),
from: emailConfig.require('from'),
messageStream: emailConfig.require('messageStream'),
});

return (
new ServiceDeployment(
'workflow-service',
{
imagePullSecret: docker.secret,
env: {
...environment.envVars,
SENTRY: sentry.enabled ? '1' : '0',
EMAIL_PROVIDER: 'postmark',
HEARTBEAT_ENDPOINT: heartbeat ?? '',
OPENTELEMETRY_COLLECTOR_ENDPOINT:
observability.enabled && observability.tracingEndpoint
? observability.tracingEndpoint
: '',
LOG_JSON: '1',
},
readinessProbe: '/_readiness',
livenessProbe: '/_health',
startupProbe: '/_health',
exposesMetrics: true,
image,
replicas: environment.podsConfig.general.replicas,
},
[],
)
// PG
.withSecret('POSTGRES_HOST', postgres.pgBouncerSecret, 'host')
.withSecret('POSTGRES_PORT', postgres.pgBouncerSecret, 'port')
.withSecret('POSTGRES_USER', postgres.pgBouncerSecret, 'user')
.withSecret('POSTGRES_PASSWORD', postgres.pgBouncerSecret, 'password')
.withSecret('POSTGRES_DB', postgres.pgBouncerSecret, 'database')
.withSecret('POSTGRES_SSL', postgres.pgBouncerSecret, 'ssl')
.withSecret('EMAIL_FROM', postmarkSecret, 'from')
.withSecret('EMAIL_PROVIDER_POSTMARK_TOKEN', postmarkSecret, 'token')
.withSecret('EMAIL_PROVIDER_POSTMARK_MESSAGE_STREAM', postmarkSecret, 'messageStream')
.withConditionalSecret(sentry.enabled, 'SENTRY_DSN', sentry.secret, 'dsn')
.deploy()
);
}
26 changes: 23 additions & 3 deletions docker/docker-compose.community.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,8 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: '${REDIS_PASSWORD}'
TOKENS_ENDPOINT: http://tokens:3003
WEBHOOKS_ENDPOINT: http://webhooks:3005
SCHEMA_ENDPOINT: http://schema:3002
SCHEMA_POLICY_ENDPOINT: http://policy:3012
EMAILS_ENDPOINT: http://emails:3011
ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}'
WEB_APP_URL: '${HIVE_APP_BASE_URL}'
PORT: 3001
Expand Down Expand Up @@ -341,11 +339,33 @@ services:
EMAIL_FROM: no-reply@graphql-hive.com
EMAIL_PROVIDER: sendmail
LOG_LEVEL: '${LOG_LEVEL:-debug}'
OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}'
SENTRY: '${SENTRY:-0}'
SENTRY_DSN: '${SENTRY_DSN:-}'
PROMETHEUS_METRICS: '${PROMETHEUS_METRICS:-}'

workflows:
image: '${DOCKER_REGISTRY}workflows${DOCKER_TAG}'
networks:
- 'stack'
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3014
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DB: '${POSTGRES_DB}'
POSTGRES_USER: '${POSTGRES_USER}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
EMAIL_FROM: no-reply@graphql-hive.com
EMAIL_PROVIDER: sendmail
LOG_LEVEL: '${LOG_LEVEL:-debug}'
SENTRY: '${SENTRY:-0}'
SENTRY_DSN: '${SENTRY_DSN:-}'
PROMETHEUS_METRICS: '${PROMETHEUS_METRICS:-}'
LOG_JSON: '1'

usage:
image: '${DOCKER_REGISTRY}usage${DOCKER_TAG}'
networks:
Expand Down
23 changes: 23 additions & 0 deletions docker/docker.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,27 @@ target "webhooks" {
]
}

target "workflows" {
inherits = ["service-base", get_target()]
contexts = {
dist = "${PWD}/packages/services/workflows/dist"
shared = "${PWD}/docker/shared"
}
args = {
SERVICE_DIR_NAME = "@hive/workflows"
IMAGE_TITLE = "graphql-hive/workflows"
IMAGE_DESCRIPTION = "The workflow service of the GraphQL Hive project."
PORT = "3013"
HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness"
}
tags = [
local_image_tag("workflows"),
stable_image_tag("workflows"),
image_tag("workflows", COMMIT_SHA),
image_tag("workflows", BRANCH_NAME)
]
}

target "composition-federation-2" {
inherits = ["service-base", get_target()]
contexts = {
Expand Down Expand Up @@ -424,6 +445,7 @@ group "build" {
"commerce",
"composition-federation-2",
"app",
"workflows",
"otel-collector"
]
}
Expand All @@ -441,6 +463,7 @@ group "integration-tests" {
"webhooks",
"server",
"composition-federation-2",
"workflows",
"otel-collector"
]
}
Expand Down
8 changes: 7 additions & 1 deletion integration-tests/docker-compose.integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ services:
CLICKHOUSE_PORT: '8123'
CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}'
CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}'
EMAILS_ENDPOINT: http://emails:3011
STRIPE_SECRET_KEY: empty
PORT: 3009

Expand Down Expand Up @@ -246,6 +245,13 @@ services:
ports:
- '3011:3011'

workflows:
environment:
EMAIL_PROVIDER: '${EMAIL_PROVIDER}'
LOG_LEVEL: debug
ports:
- '3014:3014'

schema:
environment:
LOG_LEVEL: debug
Expand Down
12 changes: 9 additions & 3 deletions integration-tests/testkit/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export interface Email {
body: string;
}

export async function history(): Promise<Email[]> {
const emailsAddress = await getServiceHost('emails', 3011);
export async function history(forEmail?: string): Promise<Email[]> {
const emailsAddress = await getServiceHost('workflows', 3014);

const response = await fetch(`http://${emailsAddress}/_history`, {
method: 'GET',
Expand All @@ -17,5 +17,11 @@ export async function history(): Promise<Email[]> {
},
});

return response.json();
const result: Email[] = await response.json();

if (!forEmail) {
return result;
}

return result.filter(result => result.to === forEmail);
}
1 change: 1 addition & 0 deletions integration-tests/testkit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const LOCAL_SERVICES = {
external_composition: 3012,
mock_server: 3042,
'otel-collector': 4318,
workflows: 3014,
} as const;

export type KnownServices = keyof typeof LOCAL_SERVICES;
Expand Down
20 changes: 12 additions & 8 deletions integration-tests/tests/api/rate-limit/emails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@
return filterEmailsByOrg(organization.slug, sent)?.length === 1;
});

let sent = await emails.history();
expect(sent).toContainEqual({
to: ownerEmail,
subject: `${organization.slug} is approaching its rate limit`,
body: expect.any(String),
});
let sent = await emails.history(ownerEmail);

expect(sent).toEqual([
{
to: ownerEmail,
subject: `${organization.slug} is approaching its rate limit`,
body: expect.any(String),
},
]);

expect(filterEmailsByOrg(organization.slug, sent)).toHaveLength(1);

// Collect operations and check for rate-limit reached
Expand All @@ -70,11 +74,11 @@

// wait for the quota email to send...
await pollFor(async () => {
let sent = await emails.history();
let sent = await emails.history(ownerEmail);
return filterEmailsByOrg(organization.slug, sent)?.length === 2;
});

sent = await emails.history();
sent = await emails.history(ownerEmail);

expect(sent).toContainEqual({
to: ownerEmail,
Expand All @@ -92,5 +96,5 @@

// Nothing new
sent = await emails.history();
expect(filterEmailsByOrg(organization.slug, sent)).toHaveLength(2);

Check failure on line 99 in integration-tests/tests/api/rate-limit/emails.spec.ts

View workflow job for this annotation

GitHub Actions / test / integration (2)

tests/api/rate-limit/emails.spec.ts > rate limit approaching and reached for organization

AssertionError: expected [ { …(2) }, { …(2) }, { …(2) }, …(1) ] to have a length of 2 but got 4 - Expected + Received - 2 + 4 ❯ tests/api/rate-limit/emails.spec.ts:99:54
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type MigrationExecutor } from '../pg-migrator';

export default {
name: '2025.12.12T00-00-00.workflows-deduplication.ts',
run: ({ sql }) => sql`
CREATE TABLE "graphile_worker_deduplication" (
"task_name" text NOT NULL,
"dedupe_key" text NOT NULL,
"expires_at" timestamptz NOT NULL,
CONSTRAINT "dedupe_pk" PRIMARY KEY ("task_name", "dedupe_key")
);

CREATE INDEX "graphile_worker_deduplication_expires_at_idx"
ON "graphile_worker_deduplication" ("expires_at")
;
`,
} satisfies MigrationExecutor;
1 change: 1 addition & 0 deletions packages/migrations/src/run-pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
await import('./actions/2025.10.16T00-00-00.schema-log-by-commit-ordered'),
await import('./actions/2025.10.17T00-00-00.project-access-tokens'),
await import('./actions/2025.11.12T00-00-00.granular-oidc-role-permissions'),
await import('./actions/2025.12.12T00-00-00.workflows-deduplication'),
],
});
3 changes: 1 addition & 2 deletions packages/services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@
"@graphql-inspector/core": "7.0.3",
"@graphql-tools/merge": "9.1.1",
"@hive/cdn-script": "workspace:*",
"@hive/emails": "workspace:*",
"@hive/schema": "workspace:*",
"@hive/service-common": "workspace:*",
"@hive/storage": "workspace:*",
"@hive/tokens": "workspace:*",
"@hive/usage-common": "workspace:*",
"@hive/usage-ingestor": "workspace:*",
"@hive/webhooks": "workspace:*",
"@hive/workflows": "workspace:*",
"@nodesecure/i18n": "^4.0.1",
"@nodesecure/js-x-ray": "8.0.0",
"@octokit/app": "15.1.4",
Expand Down
Loading
Loading