feat: add generic queue and subscription retirement
This commit is contained in:
@@ -26,8 +26,10 @@ type ApplicationPage struct {
|
||||
|
||||
// AccessQueue 是接入配置可选的队列。
|
||||
type AccessQueue struct {
|
||||
ID core.ID `json:"id"` // ID 队列标识。
|
||||
Name string `json:"name"` // Name 队列名称。
|
||||
Paused bool `json:"paused"` // Paused 暂停执行,不能表示已退役。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"` // RetiredAt 永久退役时间,历史仍可查询。
|
||||
ID core.ID `json:"id"` // ID 队列标识。
|
||||
Name string `json:"name"` // Name 队列名称。
|
||||
}
|
||||
|
||||
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||
@@ -44,11 +46,13 @@ type AccessCredential struct {
|
||||
|
||||
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||
type ApplicationDetail struct {
|
||||
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
ActiveSubscriptions uint64 `json:"active_subscriptions"` // ActiveSubscriptions 未退役订阅数量,包含暂停订阅。
|
||||
SubscriptionLimit uint32 `json:"subscription_limit"` // SubscriptionLimit 命名空间活动订阅上限。
|
||||
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
}
|
||||
|
||||
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
|
||||
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
|
||||
NotFound = New("NOT_FOUND", "resource not found")
|
||||
Retired = New("RESOURCE_RETIRED", "resource is retired; history remains available")
|
||||
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
|
||||
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
|
||||
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
|
||||
|
||||
@@ -63,6 +63,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
finished := retryStart(t, g)
|
||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||
assertApplicationCounts(t, f, 4, 2, 1)
|
||||
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_retired,DROP INDEX ix_subscription_active,DROP COLUMN retired_at,DROP COLUMN retired_sequence`)
|
||||
f.exec(`ALTER TABLE pulse_queues DROP CHECK ck_queue_retired,DROP COLUMN retired_at`)
|
||||
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
||||
f.exec(`ALTER TABLE orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_observe_state_available`)
|
||||
|
||||
@@ -933,3 +933,13 @@ CREATE TABLE orbit_archive_jobs (
|
||||
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
|
||||
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';
|
||||
|
||||
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||
ALTER TABLE pulse_queues
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||
ALTER TABLE orbit_subscriptions
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
@@ -106,13 +107,13 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,name FROM pulse_queues WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=? ORDER BY (retired_at IS NOT NULL),paused,created_at,id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -123,6 +124,10 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.SubscriptionLimit = events.MaxSubscriptions
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, namespace).Scan(&out.ActiveSubscriptions); err != nil {
|
||||
return err
|
||||
}
|
||||
out.TokenVaultEnabled = s.vault != nil
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, af
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := `SELECT id,name FROM pulse_queues WHERE namespace_id=?`
|
||||
query := `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=?`
|
||||
args := []any{t.NamespaceID}
|
||||
if after != "" {
|
||||
query += ` AND id>?`
|
||||
@@ -120,7 +120,7 @@ func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, af
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queues = append(out.Queues, q)
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewEventStore(db *sql.DB) *EventStore { return &EventStore{NewTaskStore(db)
|
||||
|
||||
var _ events.Store = (*EventStore)(nil)
|
||||
|
||||
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at`
|
||||
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at,retired_at,retired_sequence`
|
||||
|
||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||
@@ -52,7 +52,7 @@ func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Perm
|
||||
func scanSubscription(row scanner) (events.Subscription, error) {
|
||||
var out events.Subscription
|
||||
var raw []byte
|
||||
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
|
||||
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt, &out.RetiredAt, &out.RetiredSequence)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -87,6 +87,9 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -102,7 +105,7 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
||||
return events.HistoryExpired
|
||||
}
|
||||
var count int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil {
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, t.NamespaceID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= events.MaxSubscriptions {
|
||||
@@ -158,6 +161,9 @@ func (s *EventStore) ControlSubscription(ctx context.Context, t auth.Token, id c
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if before.RetiredAt != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 33
|
||||
const freshInstallBaselineVersion uint64 = 34
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||
ALTER TABLE pulse_queues
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||
ALTER TABLE orbit_subscriptions
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
175
internal/storage/mysql/resource_retirement.go
Normal file
175
internal/storage/mysql/resource_retirement.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// requireActiveQueue 对创建/恢复工作取得共享锁,与退役排他锁串行;历史读取不使用此检查。
|
||||
func requireActiveQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) error {
|
||||
var retired *time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&retired)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if retired != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func retirementBusy(message string) error { return fault.New("RESOURCE_BUSY", message) }
|
||||
|
||||
// RetireSubscription 锁定提交水位后锁订阅,既不丢未物化结果,也不丢过期交付。
|
||||
// 与创建共用流锁串行化活动名额;消费只锁订阅,不反向锁流/队列。
|
||||
func (s *EventStore) RetireSubscription(ctx context.Context, t auth.Token, id core.ID, key string, in events.RetireInput) (events.SubscriptionResult, error) {
|
||||
var out events.SubscriptionResult
|
||||
err := s.transaction(ctx, t, "events:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readSubscription(ctx, tx, t, p, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "subscription.retire", eventScope(t.CredentialID, id), key, "subscription", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err = json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readSubscription(ctx, tx, t, p, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if before.RetiredAt != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if before.PendingCount != 0 {
|
||||
return retirementBusy("subscription has unacknowledged deliveries; drain or resolve expired deliveries first")
|
||||
}
|
||||
var pending bool
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND state IN ('PENDING','LEASED','EXPIRED'))`, t.NamespaceID, id).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return retirementBusy("subscription has unacknowledged deliveries")
|
||||
}
|
||||
if before.CursorSequence < retained-1 {
|
||||
return events.HistoryExpired
|
||||
}
|
||||
filter, err := json.Marshal(before.Types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 缺失来源事件也拒绝退役,不能将损坏记录误当成不匹配过滤器。
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_result_events r LEFT JOIN orbit_events e ON e.namespace_id=r.namespace_id AND e.id=r.id WHERE r.namespace_id=? AND r.queue_id=? AND r.sequence>? AND r.sequence<=? AND (e.id IS NULL OR JSON_CONTAINS(?,JSON_QUOTE(e.type))))`, t.NamespaceID, before.QueueID, before.CursorSequence, head, string(filter)).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return retirementBusy("subscription has unconsumed results; resume consumption and acknowledge them before retirement")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orbit_subscriptions SET enabled=FALSE,retired_at=UTC_TIMESTAMP(6),retired_sequence=?,cursor_sequence=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, head, head, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Subscription, err = readSubscription(ctx, tx, t, p, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, t, identity, id, "subscription", "subscription.retire", in.Reason, before, out.Subscription); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RetireQueue 按应用额度→身份→队列持锁,拒绝尚未结清的工作和未来工作来源。
|
||||
// 暂停计划可以保留;其恢复/发布与本操作共用队列锁,退役后无法重新激活。
|
||||
func (s *TaskStore) RetireQueue(ctx context.Context, t auth.Token, id core.ID, key string, in service.RetireQueueInput) (service.QueueResult, error) {
|
||||
var out service.QueueResult
|
||||
err := s.transaction(ctx, t, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(id) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "queue.retire", string(id), key, "queue", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err = json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if _, _, err = lockQueueAfterApplication(ctx, tx, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readQueue(ctx, tx, t.NamespaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if before.UnfinishedTasks != 0 {
|
||||
return retirementBusy("queue has unfinished tasks, including tasks needing review")
|
||||
}
|
||||
checks := []struct {
|
||||
query, message string
|
||||
args []any
|
||||
}{
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_tasks WHERE namespace_id=? AND queue_id=? AND state IN ('WAITING','LEASED','RUNNING','NEEDS_REVIEW'))`, "queue has unfinished or uncertain tasks", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_attempts a JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND a.state IN ('LEASED','RUNNING'))`, "queue has active execution attempts", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_permits p JOIN pulse_attempts a ON a.namespace_id=p.namespace_id AND a.id=p.attempt_id JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND p.released_at IS NULL)`, "queue has unreleased execution permits", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_schedules WHERE namespace_id=? AND queue_id=? AND paused=FALSE)`, "queue has enabled schedules; pause them first", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM weave_instances i LEFT JOIN weave_version_scopes v ON v.namespace_id=i.namespace_id AND v.definition_version_id=i.definition_version_id WHERE i.namespace_id=? AND i.state IN ('RUNNING','WAITING','CANCELLING','NEEDS_REVIEW') AND (v.definition_version_id IS NULL OR JSON_CONTAINS(v.queue_ids,JSON_QUOTE(?))))`, "queue is referenced by unfinished workflows", []any{t.NamespaceID, string(id)}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=? AND retired_at IS NULL)`, "queue has subscriptions; retire them first", []any{t.NamespaceID, id}},
|
||||
}
|
||||
for _, check := range checks {
|
||||
var busy bool
|
||||
if err = tx.QueryRowContext(ctx, check.query, check.args...).Scan(&busy); err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
return retirementBusy(check.message)
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE pulse_queues SET paused=TRUE,retired_at=UTC_TIMESTAMP(6),revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queue, err = readQueue(ctx, tx, t.NamespaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, t, identity, id, "queue", "queue.retire", in.Reason, before, out.Queue); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
func addRetirementPermission(t *testing.T, f *executionFixture) {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
var p auth.Permissions
|
||||
if err := f.db.QueryRowContext(f.ctx, `SELECT permissions FROM orbit_credentials WHERE namespace_id=? AND id=?`, f.token.NamespaceID, f.token.CredentialID).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Actions = append(p.Actions, "queues:manage", "queues:read")
|
||||
raw, _ = json.Marshal(p)
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.token.NamespaceID, f.token.CredentialID)
|
||||
}
|
||||
func TestIntegrationResourceRetirement(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer stop()
|
||||
db, err := Open(ctx, dsn, 32, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("no_lost_backlog_leases_or_history", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{MaxAttempts: 1})
|
||||
task := f.cancelResult()
|
||||
in := events.RetireInput{ExpectedRevision: sub.Revision, Reason: "completed temporary resources"}
|
||||
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
g := f.grant(sub.ID)
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
f.expireDelivery(g)
|
||||
noEventGrant(t, f, sub.ID)
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
f.ack(sub.ID, g)
|
||||
done, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
if err != nil || done.Subscription.RetiredAt == nil || done.Subscription.RetiredSequence == nil || done.Subscription.Enabled {
|
||||
t.Fatalf("retire: %+v %v", done, err)
|
||||
}
|
||||
again, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
if err != nil || !again.Deduplicated || again.Subscription.Revision != done.Subscription.Revision {
|
||||
t.Fatal(again, err)
|
||||
}
|
||||
changed := in
|
||||
changed.Reason = "other"
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", changed)
|
||||
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||
_, err = f.events.Control(ctx, f.token, sub.ID, "enable", events.ControlInput{ExpectedRevision: done.Subscription.Revision, Enabled: true, Reason: "attempt resume"})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
detail, err := f.events.Detail(ctx, f.token, sub.ID, g.Delivery.ID)
|
||||
if err != nil || detail.Event.AggregateID != task.ID {
|
||||
t.Fatal("history unavailable", err)
|
||||
}
|
||||
f.cancelResult()
|
||||
noEventGrant(t, f, sub.ID)
|
||||
if f.count("orbit_audit_events", "action='subscription.retire'") != 1 {
|
||||
t.Fatal("retirement audit missing or duplicated")
|
||||
}
|
||||
})
|
||||
t.Run("paused_counts_retired_releases_capacity", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
paused, err := f.events.Control(ctx, f.token, sub.ID, "pause", events.ControlInput{ExpectedRevision: sub.Revision, Enabled: false, Reason: "pause"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i < events.MaxSubscriptions; i++ {
|
||||
f.create(events.CreateInput{Name: fmt.Sprintf("capacity-%d", i)})
|
||||
}
|
||||
_, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID})
|
||||
expectWorkerCode(t, err, "SUBSCRIPTION_LIMIT")
|
||||
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: paused.Subscription.Revision, Reason: "retire paused"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.count("orbit_subscriptions", "1=1") != 65 {
|
||||
t.Fatal("history was removed")
|
||||
}
|
||||
})
|
||||
t.Run("queue_protects_tasks_subscriptions_and_original_receipts", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
q := f.resources.QueueID
|
||||
sub := f.create(events.CreateInput{})
|
||||
task := f.submit(1)
|
||||
in := service.RetireQueueInput{ExpectedRevision: 1, Reason: "queue lifecycle"}
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
if _, err = f.tasks.CancelTask(ctx, f.token, task.ID, "cancel", service.CancelInput{ExpectedRevision: task.Revision, Reason: "finish test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
g := f.grant(sub.ID)
|
||||
f.ack(sub.ID, g)
|
||||
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "retire"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retired, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
if err != nil || retired.Queue.RetiredAt == nil || !retired.Queue.Paused {
|
||||
t.Fatal(retired, err)
|
||||
}
|
||||
replay, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
if err != nil || !replay.Deduplicated {
|
||||
t.Fatal(replay, err)
|
||||
}
|
||||
_, err = f.tasks.ConfigureQueue(ctx, f.token, q, "resume", service.QueueConfigInput{ExpectedRevision: retired.Queue.Revision, Reason: "resume", MaxConcurrency: 1, RatePerSecond: 1, MaxUnfinishedTasks: 1})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
_, err = f.tasks.Submit(ctx, f.token, "new-task", service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
_, err = f.events.Create(ctx, f.token, "new-sub", events.CreateInput{Name: "new", QueueID: q})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
if _, err = f.tasks.GetTask(ctx, f.token, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := newEventFixture(t, ctx, db)
|
||||
_, err = other.events.Retire(ctx, other.token, sub.ID, "foreign", events.RetireInput{ExpectedRevision: 1, Reason: "foreign"})
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
})
|
||||
t.Run("schedule_resume_cannot_revive_retired_queue", func(t *testing.T) {
|
||||
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||
addRetirementPermission(t, f.executionFixture)
|
||||
v := f.create(f.definition())
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
pause, err := f.api.Control(ctx, f.token, v.ID, "pause", schedule.ControlInput{ExpectedRevision: v.Revision, Reason: "pause before retire"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.api.Control(ctx, f.token, v.ID, "resume", schedule.ControlInput{ExpectedRevision: pause.Schedule.Revision, Reason: "resume"}, false)
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
if _, err = f.api.Get(ctx, f.token, v.ID); err != nil {
|
||||
t.Fatal("schedule history", err)
|
||||
}
|
||||
})
|
||||
t.Run("waiting_workflow_with_future_queue_reference_blocks_retirement", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
spec := runtimeSpec(f.resources.QueueID, 1)
|
||||
spec.Body.Steps = append([]definition.Step{{Key: "wait", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 60000}}}, spec.Body.Steps...)
|
||||
runtimeStart(t, f, spec)
|
||||
addRetirementPermission(t, f)
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
})
|
||||
t.Run("completed_workflow_receipt_survives_queue_retirement", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
grantWorkflowInstance(t, f)
|
||||
input := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
api := instance.New(NewWorkflowInstanceStore(db))
|
||||
key := string(executionID(t))
|
||||
first, err := api.Start(ctx, f.token, key, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := retryStart(t, f)
|
||||
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
runtimeView(t, f, first.Instance.ID, "SUCCEEDED")
|
||||
addRetirementPermission(t, f)
|
||||
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "completed workflow"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, err := api.Start(ctx, f.token, key, input)
|
||||
if err != nil || !replay.Deduplicated || replay.Instance.ID != first.Instance.ID {
|
||||
t.Fatal("receipt after retirement", err)
|
||||
}
|
||||
if _, err = api.Get(ctx, f.token, first.Instance.ID); err != nil {
|
||||
t.Fatal("workflow history", err)
|
||||
}
|
||||
_, err = api.Start(ctx, f.token, "new-instance", input)
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
})
|
||||
t.Run("submit_and_retire_serialize_under_real_database_contention", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
key := string(executionID(t))
|
||||
q := f.resources.QueueID
|
||||
results := competeWorkflow(t, f, nil, func() error {
|
||||
_, e := f.tasks.Submit(ctx, f.token, key, service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
return e
|
||||
}, func() error {
|
||||
_, e := f.tasks.RetireQueue(ctx, f.token, q, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "concurrent retirement"})
|
||||
return e
|
||||
})
|
||||
if results[0] == nil {
|
||||
expectWorkerCode(t, results[1], "RESOURCE_BUSY")
|
||||
} else {
|
||||
expectWorkerCode(t, results[0], "RESOURCE_RETIRED")
|
||||
if results[1] != nil {
|
||||
t.Fatal(results[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_rolls_back_retirement_and_quota", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
f.exec(`CREATE TRIGGER retirement_audit_reject BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='retirement audit unavailable'`)
|
||||
defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS retirement_audit_reject`)
|
||||
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "audit"})
|
||||
if err == nil {
|
||||
t.Fatal("audit failure accepted")
|
||||
}
|
||||
if f.subscription(sub.ID).RetiredAt != nil {
|
||||
t.Fatal("partial retirement committed")
|
||||
}
|
||||
})
|
||||
var foreignKeys int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||
t.Fatal("foreign keys", foreignKeys, err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,13 @@ func TestIntegrationResultSubscriptionsUpgrade(t *testing.T) {
|
||||
if err = CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var retired *time.Time
|
||||
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=?`, ns, queue).Scan(&retired); err != nil || retired != nil {
|
||||
t.Fatal("migration unexpectedly retired legacy queue", err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM orbit_subscriptions WHERE namespace_id=? AND id=?`, ns, sub).Scan(&retired); err != nil || retired != nil {
|
||||
t.Fatal("migration unexpectedly retired legacy subscription", err)
|
||||
}
|
||||
var enabled bool
|
||||
var queueID *core.ID
|
||||
var filter string
|
||||
|
||||
@@ -129,6 +129,9 @@ func (s *ScheduleStore) Create(ctx context.Context, t auth.Token, key string, in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.Definition.Task.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -252,6 +255,13 @@ func (s *ScheduleStore) mutate(ctx context.Context, t auth.Token, id core.ID, ke
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
current, err := readSchedule(ctx, tx, t.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = requireActiveQueue(ctx, tx, t.NamespaceID, current.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readSchedule(ctx, tx, t.NamespaceID, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -374,7 +374,7 @@ func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key strin
|
||||
}
|
||||
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
|
||||
var q service.QueueView
|
||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision)
|
||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision,retired_at FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision, &q.RetiredAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ func lockQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, ma
|
||||
|
||||
// lockQueueAfterApplication 仅在同一事务已持有应用额度排他锁时调用。
|
||||
func lockQueueAfterApplication(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
|
||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
|
||||
var retired *time.Time
|
||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency,retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max, &retired)
|
||||
if err == nil && retired != nil {
|
||||
err = fault.Retired
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ func (s *ExecutionStore) Connect(ctx context.Context, token auth.Token, in worke
|
||||
// 不依赖数据库外键:创建会话前验证队列存在且属于当前命名空间。
|
||||
// 进程头在队列之前加锁,与执行命令保持一致;失败时回滚新建的进程头。
|
||||
var queue core.ID
|
||||
if err := requireActiveQueue(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, token.NamespaceID, in.QueueID).Scan(&queue); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
|
||||
@@ -129,10 +129,11 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
||||
if len(version.Analysis.Queues) > 0 && !p.Allows("tasks:submit") {
|
||||
return fault.Forbidden
|
||||
}
|
||||
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
// 已完成流程所用队列可以退役;原受理回执仍核对当前读取范围,但不重新申请执行权。
|
||||
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,6 +147,9 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := definition.ValidateData(version.Specification.InputType, in.Input); err != nil {
|
||||
return fault.Invalid("workflow input does not match fixed version")
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
const raw = await response.text(); let body = {};
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
||||
if (!response.ok) { const error = new Error(window.orbitResourceMessage(body?.error?.message || body?.message || `HTTP ${response.status}`)); error.status = response.status; throw error; }
|
||||
return body;
|
||||
} finally { if (mutation) state.mutations--; }
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
for (const item of items) {
|
||||
const option = document.createElement('option');
|
||||
option.value = kind === 'application' ? item.namespace_id : item.id;
|
||||
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : item.name;
|
||||
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : `${item.name}${item.retired_at ? ' · 已退役' : item.paused ? ' · 已暂停' : ''}`;
|
||||
option.disabled = kind === 'application' && !item.enabled;
|
||||
select.append(option);
|
||||
}
|
||||
@@ -455,7 +455,7 @@
|
||||
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
||||
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
||||
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>当前应用与队列还没有创建订阅</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.retired_at ? '已退役' : item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||
el.querySelectorAll('.subscription-row').forEach(row => {
|
||||
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
||||
@@ -731,9 +731,9 @@
|
||||
}
|
||||
function renderSubscriptionDetail(subscription) {
|
||||
state.selectedSubscription = subscription;
|
||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.retired_at ? '已退役' : subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
|
||||
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||
$('subscription-actions').hidden = !!subscription.retired_at; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', subscription.retired_at ? `退役于 ${formatTime(subscription.retired_at)},截止水位 ${subscription.retired_sequence};未来结果不再订阅,历史交付仍可查询。` : '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||
}
|
||||
function renderSubscriptionDeliveries(payload, error) {
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
@@ -741,16 +741,18 @@
|
||||
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
|
||||
}
|
||||
function openSubscriptionAction() {
|
||||
let subscriptionRetirement = null;
|
||||
function openSubscriptionAction(mode) {
|
||||
if (!state.selectedSubscription) return;
|
||||
subscriptionRetirement = null;
|
||||
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
|
||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
|
||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); if (mode === 'RETIRE') { state.subscriptionActionMode = 'RETIRE'; text('subscription-action-title','永久退役订阅'); text('subscription-action-description','仅在历史结果全部确认后成功。退役不可恢复,后续结果不再订阅;历史交付和审计保留,活动名额释放。'); text('subscription-action-submit','确认退役 →'); } $('subscription-action-reason').focus();
|
||||
}
|
||||
async function submitSubscriptionAction(event) {
|
||||
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
|
||||
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
|
||||
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
try { const retire = state.subscriptionActionMode === 'RETIRE'; if (retire) subscriptionRetirement ||= {key:operationKey('subscription-retire'),body:{expected_revision:String(subscription.revision),reason}}; await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:${retire ? 'retire' : 'configure'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retire ? subscriptionRetirement.key : operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify(retire ? subscriptionRetirement.body : { expected_revision: String(subscription.revision), enabled, reason }) }); showToast(retire ? '订阅已退役,历史保留' : enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
@@ -814,7 +816,7 @@
|
||||
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-retire-button').addEventListener('click', () => openSubscriptionAction('RETIRE')); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
let tokenRequest = 0, tokenTarget = null;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
const resourceMessages = {
|
||||
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
|
||||
'queue has unfinished tasks, including tasks needing review':'队列还有未完成或待核查任务,请先结清。',
|
||||
'queue has unfinished or uncertain tasks':'队列还有未完成或结果不确定的任务,请先核查。',
|
||||
'queue has active execution attempts':'队列仍有在途执行,请等待结束。',
|
||||
'queue has unreleased execution permits':'队列仍有未释放的执行许可,请先核查在途工作。',
|
||||
'queue has enabled schedules; pause them first':'队列还有启用中的定时计划,请先暂停计划。',
|
||||
'queue is referenced by unfinished workflows':'队列仍被未结束的流程引用,请先结清流程。',
|
||||
'queue has subscriptions; retire them first':'队列还有未退役订阅,请先结清并退役订阅。',
|
||||
'subscription has unacknowledged deliveries; drain or resolve expired deliveries first':'订阅还有未确认结果,请先消费并确认;过期交付需要核查后重试。',
|
||||
'subscription has unacknowledged deliveries':'订阅还有未确认结果,请先结清。',
|
||||
'subscription has unconsumed results; resume consumption and acknowledge them before retirement':'还有尚未投递的结果,请恢复消费并全部确认后再退役。'
|
||||
};
|
||||
window.orbitResourceMessage = text => resourceMessages[text] || text;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : window.orbitResourceMessage(error.message); }
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json().catch(() => ({}));
|
||||
@@ -12,7 +26,11 @@
|
||||
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||
return value.data;
|
||||
}
|
||||
const queueMemory = new Map();
|
||||
function rememberedQueue(namespace) { try { return localStorage.getItem(`orbit.access.queue.${namespace}`) || queueMemory.get(namespace) || ''; } catch { return queueMemory.get(namespace) || ''; } }
|
||||
function rememberQueue() { const value = $('access-queue').value; queueMemory.set(ui.selected, value); try { localStorage.setItem(`orbit.access.queue.${ui.selected}`, value); } catch {} renderConfig(); }
|
||||
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
if (!queue) throw new Error('请先选择接入队列');
|
||||
const url = $('access-url').value.trim() || location.origin;
|
||||
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
@@ -30,15 +48,21 @@
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
async function select(namespace) {
|
||||
if (namespace !== ui.selected) { retirement = null; $('access-retire-form').hidden = true; }
|
||||
const requested = namespace; ui.selected = namespace;
|
||||
try {
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
|
||||
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||
const previous = $('access-queue').value;
|
||||
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
|
||||
const previous = rememberedQueue(namespace);
|
||||
const usable = detail.queues.filter(q => !q.retired_at);
|
||||
$('access-queue').innerHTML = '<option value="">请选择接入队列</option>' + detail.queues.map(q => `<option value="${escape(q.id)}" ${q.retired_at ? 'disabled' : ''}>${escape(q.name)} · ${q.retired_at ? '已退役' : q.paused ? '已暂停' : '可用'} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
$('access-queue').value = usable.some(q => q.id === previous) ? previous : (!detail.truncated && usable.length === 1 ? usable[0].id : '');
|
||||
$('access-capacity').textContent = `订阅名额 ${detail.active_subscriptions ?? '—'} / ${detail.subscription_limit ?? '—'} · 暂停仍占名额,结清后退役释放名额。`;
|
||||
const availableCredential = detail.credentials.find(c => !c.revoked_at);
|
||||
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
|
||||
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
const now = Date.now();
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
@@ -47,7 +71,7 @@
|
||||
const blocked = !!c.revoked_at || transitioning;
|
||||
const helpID = `access-rotation-help-${c.id}`;
|
||||
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>${c.token_stored ? '请在上方复制当前接入配置,更新服务、Worker 和结果消费者。' : '当前新 Token 尚未补存,请先从已保存的新配置导入;过渡期旧 Token 不能替代当前新 Token。'}确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential">
|
||||
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
|
||||
@@ -63,7 +87,7 @@
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function renderConfig() { const q = ui.detail?.queues.find(q => q.id === $('access-queue').value); $('access-queue-note').textContent = !q ? '请明确选择接入队列,避免复制到错误的目标。' : `当前目标:${q.name} · ${q.paused ? '已暂停,任务暂不执行' : '可用'}。选择只影响复制配置,不修改服务端路由。`; $('access-retire-queue').disabled = !q || !!q.retired_at; try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function closeToken() {
|
||||
tokenRequest++; tokenTarget = null;
|
||||
$('access-token-import-submit').disabled = false;
|
||||
@@ -74,7 +98,8 @@
|
||||
closeToken();
|
||||
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
|
||||
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
if (!tokenTarget.queue) { $('access-error').textContent = '请先选择要接入的队列。'; return; }
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) { $('access-error').textContent = '当前凭据没有所选队列权限,请选择已授权队列。'; return; }
|
||||
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
|
||||
$('access-token-error').textContent = '';
|
||||
$('access-token-import').hidden = credential.token_stored;
|
||||
@@ -206,12 +231,29 @@
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', rememberQueue);
|
||||
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
|
||||
$('access-form').addEventListener('submit', submit);
|
||||
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||
let retirement = null;
|
||||
async function queueAPI(path, body, key, namespace = ui.selected) {
|
||||
const response = await fetch(path, {method: body ? 'POST' : 'GET', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json', 'X-Orbit-Console':'1','X-Orbit-Namespace':namespace,...(key ? {'Idempotency-Key':key} : {})}, ...(body ? {body:JSON.stringify(body)} : {})});
|
||||
const value = await response.json(); if (!response.ok) throw new Error(value.error?.message || '操作失败'); return value.data;
|
||||
}
|
||||
$('access-retire-queue').addEventListener('click', async () => {
|
||||
const id = $('access-queue').value, namespace = ui.selected; if (!id) return;
|
||||
$('access-retire-form').hidden=false; $('access-retire-error').textContent='正在核对队列…'; retirement=null;
|
||||
try { const q=await queueAPI(`/v1/queues/${id}`,null,null,namespace); if (namespace!==ui.selected || id!==$('access-queue').value) return; retirement={id,namespace,revision:q.revision,key:crypto.randomUUID(),body:null}; $('access-retire-reason').value=''; $('access-retire-error').textContent=''; } catch(error) { $('access-retire-error').textContent=message(error); }
|
||||
});
|
||||
$('access-retire-cancel').addEventListener('click',()=>{retirement=null;$('access-retire-form').hidden=true;});
|
||||
$('access-retire-form').addEventListener('submit', async event=>{
|
||||
event.preventDefault(); const target=retirement; if (!target || target.namespace!==ui.selected || target.id!==$('access-queue').value) { $('access-retire-error').textContent='队列已变化,请重新打开退役操作。'; return; }
|
||||
target.body ||= {expected_revision:String(target.revision),reason:$('access-retire-reason').value.trim()};
|
||||
$('access-retire-submit').disabled=true;
|
||||
try { await queueAPI(`/v1/queues/${target.id}:retire`,target.body,target.key,target.namespace); $('access-retire-form').hidden=true; retirement=null; await load(); } catch(error) { $('access-retire-error').textContent=message(error); } finally { $('access-retire-submit').disabled=false; }
|
||||
});
|
||||
window.orbitApplications = { load };
|
||||
})();
|
||||
|
||||
@@ -223,9 +223,11 @@
|
||||
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-queue-note"></p><p class="panel-description" id="access-capacity"></p><button class="text-button" type="button" id="access-retire-queue">退役所选队列</button>
|
||||
<form id="access-retire-form" hidden><p class="panel-description">退役不可恢复。先暂停关联计划、结清任务与流程,再退役所有订阅;历史和审计继续保留。</p><label>退役原因<input id="access-retire-reason" maxlength="1024" required></label><button class="primary-button compact" type="submit" id="access-retire-submit">确认退役</button><button class="text-button" type="button" id="access-retire-cancel">取消</button><p id="access-retire-error" class="action-error"></p></form>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<p class="panel-description">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<p class="panel-description" id="access-config-note">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
@@ -374,7 +376,7 @@
|
||||
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-retire-button" type="button">退役订阅</button><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
||||
<form class="action-form" id="subscription-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS CONTROL</span><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||
|
||||
@@ -34,7 +34,7 @@ func consoleScopePath(r *http.Request) bool {
|
||||
return r.Method == "GET"
|
||||
}
|
||||
if strings.HasPrefix(p, prefix+"/") {
|
||||
return r.Method == "GET" || (resource != "queues" && r.Method == "POST")
|
||||
return r.Method == "GET" || (r.Method == "POST" && (resource != "queues" || strings.HasSuffix(p, ":retire")))
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -47,12 +47,24 @@ func RegisterEventSubscriptions(server *http.Server, s *events.Service) {
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/subscriptions/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
id, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||
if !ok || command != "configure" {
|
||||
if !ok || (command != "configure" && command != "retire") {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if command == "retire" {
|
||||
var in events.RetireInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Retire(r.Context(), t, core.ID(id), key, in)
|
||||
return out, 200, err
|
||||
}
|
||||
var in events.ControlInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -150,6 +150,21 @@ func registerTasks(mux *http.ServeMux, s *service.Service) {
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/queues/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
command := r.PathValue("command")
|
||||
if strings.HasSuffix(command, ":retire") {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in service.RetireQueueInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.RetireQueue(r.Context(), t, core.ID(strings.TrimSuffix(command, ":retire")), key, in)
|
||||
return out, 200, err
|
||||
}
|
||||
if !strings.HasSuffix(command, ":configure") {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
@@ -233,7 +248,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "RESOURCE_RETIRED": 409, "RESOURCE_BUSY": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user