Files
orbit/pulse/service/service.go

193 lines
6.9 KiB
Go

package service
import (
"bytes"
"context"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
type Service struct{ store Store }
func New(store Store) *Service { return &Service{store: store} }
var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
func ValidName(s string, max int) bool {
return len(s) > 0 && len(s) <= max && namePattern.MatchString(s)
}
func ValidateKey(key string) error {
if len(key) < 1 || len(key) > 128 {
return fault.Invalid("Idempotency-Key must contain 1 to 128 printable ASCII characters")
}
for _, c := range key {
if c < 33 || c > 126 {
return fault.Invalid("Idempotency-Key must contain printable ASCII characters without spaces")
}
}
return nil
}
func NormalizeSubmit(in SubmitInput) (SubmitInput, error) {
if in.QueueID.Validate() != nil {
return in, fault.Invalid("queue_id is invalid")
}
if !ValidName(in.Handler, 128) || !ValidName(in.HandlerVersion, 64) || !ValidName(in.PayloadVersion, 32) {
return in, fault.Invalid("handler and version names are required ASCII identifiers")
}
if len(in.Payload) == 0 || len(in.Payload) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Payload), []byte("null")) {
return in, fault.Invalid("payload must be a non-null JSON value up to 256 KiB")
}
if _, err := jsonvalue.Canonical(in.Payload); err != nil {
return in, fault.Invalid("payload is invalid, duplicated, too deep or outside numeric limits")
}
if !utf8.ValidString(in.BusinessKey) || utf8.RuneCountInString(in.BusinessKey) > 191 || strings.ContainsAny(in.BusinessKey, "\x00\r\n") {
return in, fault.Invalid("business_key must be at most 191 characters without control newlines")
}
if err := ValidateConcurrencyKey(in.ConcurrencyKey); err != nil {
return in, err
}
if in.TaskTimeoutMS == 0 {
in.TaskTimeoutMS = 24 * 60 * 60 * 1000
}
if in.TaskTimeoutMS > 30*24*60*60*1000 {
return in, fault.Invalid("task_timeout_ms exceeds 30 days")
}
if in.RecoverySafety == "" {
in.RecoverySafety = pulse.RecoveryReconcileOnly
}
if in.RecoverySafety != pulse.RecoveryReconcileOnly && in.RecoverySafety != pulse.RecoveryIdempotent {
return in, fault.Invalid("invalid recovery_safety")
}
if in.Retry.MaxAttempts == 0 {
in.Retry.MaxAttempts = 1
}
if in.Retry.AttemptTimeoutMS == 0 {
in.Retry.AttemptTimeoutMS = 30000
}
if in.Retry.MaxAttempts > 100 || in.Retry.AttemptTimeoutMS > in.TaskTimeoutMS {
return in, fault.Invalid("retry attempts or timeout exceed limits")
}
if in.Retry.MaxAttempts > 1 {
if in.Retry.InitialDelayMS == 0 {
in.Retry.InitialDelayMS = 1000
}
if in.Retry.MaxDelayMS == 0 {
in.Retry.MaxDelayMS = 30000
}
}
if err := in.Retry.Validate(); err != nil {
return in, fault.Invalid("invalid retry policy")
}
for _, t := range []**time.Time{&in.AvailableAt, &in.StartDeadlineAt} {
if *t != nil {
v := (**t).UTC()
if v.Year() < 2000 || v.Year() > 9998 || v.Nanosecond()%1000 != 0 {
return in, fault.Invalid("times must use microsecond precision and supported years")
}
*t = &v
}
}
return in, nil
}
func (s *Service) Submit(ctx context.Context, token auth.Token, key string, in SubmitInput) (SubmitResult, error) {
if err := ValidateKey(key); err != nil {
return SubmitResult{}, err
}
in, err := NormalizeSubmit(in)
if err != nil {
return SubmitResult{}, err
}
return s.store.Submit(ctx, token, key, in)
}
func (s *Service) GetTask(ctx context.Context, token auth.Token, id core.ID) (TaskView, error) {
if id.Validate() != nil {
return TaskView{}, fault.Invalid("task id is invalid")
}
return s.store.GetTask(ctx, token, id)
}
func (s *Service) ListTasks(ctx context.Context, token auth.Token, in ListInput) (TaskPage, error) {
if in.QueueID.Validate() != nil {
return TaskPage{}, fault.Invalid("queue_id is required")
}
if in.Limit == 0 {
in.Limit = 20
}
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
return TaskPage{}, fault.Invalid("invalid page limit or cursor")
}
switch in.State {
case "", pulse.TaskWaiting, pulse.TaskLeased, pulse.TaskRunning, pulse.TaskNeedsReview, pulse.TaskSucceeded, pulse.TaskFailed, pulse.TaskCancelled:
default:
return TaskPage{}, fault.Invalid("invalid task state")
}
return s.store.ListTasks(ctx, token, in)
}
func (s *Service) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in CancelInput) (CancelResult, error) {
if id.Validate() != nil {
return CancelResult{}, fault.Invalid("task id is invalid")
}
if err := ValidateKey(key); err != nil {
return CancelResult{}, err
}
if strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || utf8.RuneCountInString(in.Reason) > 1024 || in.ExpectedRevision == 0 {
return CancelResult{}, fault.Invalid("reason and nonzero expected_revision are required")
}
return s.store.CancelTask(ctx, token, id, key, in)
}
func (s *Service) CreateQueue(ctx context.Context, token auth.Token, key string, in QueueInput) (QueueResult, error) {
if err := ValidateKey(key); err != nil {
return QueueResult{}, err
}
if !ValidName(in.Name, 128) || in.MaxConcurrency == 0 || in.MaxConcurrency > 100000 || in.RatePerSecond == 0 || in.RatePerSecond > 1000000 || in.MaxUnfinishedTasks > 1000000 {
return QueueResult{}, fault.Invalid("queue name, concurrency or rate is invalid")
}
return s.store.CreateQueue(ctx, token, key, in)
}
func (s *Service) GetQueue(ctx context.Context, token auth.Token, id core.ID) (QueueView, error) {
if id.Validate() != nil {
return QueueView{}, fault.Invalid("queue id is invalid")
}
return s.store.GetQueue(ctx, token, id)
}
func (s *Service) ConfigureQueue(ctx context.Context, token auth.Token, id core.ID, key string, in QueueConfigInput) (QueueResult, error) {
if id.Validate() != nil {
return QueueResult{}, fault.Invalid("queue id is invalid")
}
if err := ValidateKey(key); err != nil {
return QueueResult{}, err
}
if in.ExpectedRevision == 0 || strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || utf8.RuneCountInString(in.Reason) > 1024 {
return QueueResult{}, fault.Invalid("reason and nonzero expected_revision are required")
}
if in.MaxConcurrency == 0 || in.MaxConcurrency > 100000 || in.RatePerSecond == 0 || in.RatePerSecond > 1000000 || in.MaxUnfinishedTasks == 0 || in.MaxUnfinishedTasks > 1000000 {
return QueueResult{}, fault.Invalid("queue limits are invalid")
}
return s.store.ConfigureQueue(ctx, token, id, key, in)
}
// ValidateConcurrencyKey 不归一化资源键,字节精确相等才共享许可。
func ValidateConcurrencyKey(key string) error {
if key == "" {
return nil
}
if !utf8.ValidString(key) || utf8.RuneCountInString(key) > 191 || strings.TrimSpace(key) != key {
return fault.Invalid("invalid concurrency_key")
}
for _, c := range key {
if unicode.IsControl(c) {
return fault.Invalid("invalid concurrency_key")
}
}
return nil
}