214 lines
8.0 KiB
Go
214 lines
8.0 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.Priority < 0 || in.Priority > 9 {
|
|
return in, fault.Invalid("priority must be between 0 and 9")
|
|
}
|
|
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.DelayMS >= in.TaskTimeoutMS || (in.DelayMS != 0 && in.AvailableAt != nil) {
|
|
return in, fault.Invalid("delay_ms must be less than task_timeout_ms and cannot be combined with available_at")
|
|
}
|
|
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")
|
|
}
|
|
if !utf8.ValidString(in.BusinessKey) || utf8.RuneCountInString(in.BusinessKey) > 191 || strings.ContainsAny(in.BusinessKey, "\x00\r\n") {
|
|
return TaskPage{}, fault.Invalid("business_key must be at most 191 characters without control newlines")
|
|
}
|
|
for _, field := range []**time.Time{&in.CreatedFrom, &in.CreatedBefore} {
|
|
if *field != nil {
|
|
v := (**field).UTC()
|
|
if v.Year() < 2000 || v.Year() > 9998 || v.Nanosecond()%1000 != 0 {
|
|
return TaskPage{}, fault.Invalid("creation bounds must use supported years and microsecond precision")
|
|
}
|
|
*field = &v
|
|
}
|
|
}
|
|
if in.CreatedFrom != nil && in.CreatedBefore != nil && !in.CreatedFrom.Before(*in.CreatedBefore) {
|
|
return TaskPage{}, fault.Invalid("created_from must be before created_before")
|
|
}
|
|
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
|
|
}
|