diff --git a/README.md b/README.md index 358db76..b8b8f84 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Shared public schemas, generated Go types and validation for Weir servers and clients. Requires Go 1.27.1. ```sh -go get github.com/batchstream/weir-protocol@v0.15.0 +go get github.com/batchstream/weir-protocol@v0.16.0 ``` The dependency direction is one-way: Weir and the Go SDK depend on this module. @@ -35,10 +35,44 @@ adapter-owned formats; new adapters need no protobuf fields or schema changes. Empty request data is permitted. Unknown valid content types are left to the selected adapter. -Each request is validated before execution. Earlier requests may have effects -when a later request fails validation. The first record index is 1; every later -request advances it by one. UINT64_MAX is invalid. Every record produces one -ExecuteResponse with its ordinal and typed result, in input order. +Each request is validated before execution. A record the server cannot admit, +such as one its adapter rejects or one sent while the Store drains, receives its +own failed result, NOT_STARTED for a mutation, and the stream continues. A +request that fails `ValidateExecuteRequest`, changes the Store or kind, breaks +the index sequence or cancels a record not yet sent fails the RPC with +INVALID_ARGUMENT after the results of the records admitted before it. A record +without a result when the RPC fails may still have effects. The first record +index is 1; every later record advances it by one. UINT64_MAX is invalid. Each +record receives at most one ExecuteResponse with its ordinal and typed result, +in completion order, so consumers match results by index. One stream can +therefore carry the records of independent callers without a slow or failed +record holding back the others. A client that shares a stream validates each +record before sending it, since an invalid request ends the stream for every +caller on it. + +`timeout_micros` optionally bounds one Read or Mutate record from the moment +the server reads it; time spent in transport buffers is not counted, so clients +still enforce their own deadline. A record still queued when it expires fails +with DEADLINE_EXCEEDED, NOT_STARTED for a mutation; an executing record reports +the evidence its cancellation leaves. Zero sets no record deadline. Native +requires zero and uses the RPC deadline. `RecordTimeout` and `TimeoutMicros` +convert between the field and Go durations without shortening or disabling a +caller's budget. + +A request that sets only `store_name` and `cancel_index` cancels an earlier +record of the same stream. It does not advance the index or receive a response. +The canceled record still receives its own result: a CANCELLED failure, +NOT_STARTED for a mutation, while it is still queued; the evidence its +cancellation leaves while it executes; and its ordinary result once it has +completed. Cancel requests let a stream shared by several callers release the +work of one caller who stops waiting. Native streams use RPC cancellation +instead. + +Each stream has room for `RecordWindow` (32) records without results, whatever +the Store queue holds. A client that never has more than 32 records without +results is never held back by the server's window, so its cancel requests take +effect as soon as they arrive; a further record waits until a result frees room. + Servers and clients enforce Store/kind consistency and index sequences. Stateless validators check each envelope and nested fields. Requests and responses reject unknown fields throughout the message tree. Responses require declared result/event diff --git a/api/protocol/protocol.go b/api/protocol/protocol.go index ab55eee..2250d52 100644 --- a/api/protocol/protocol.go +++ b/api/protocol/protocol.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" "strings" + "time" "unicode" "unicode/utf8" @@ -20,6 +21,11 @@ import ( // Backend protocol limits and actual process capacity govern business payloads. const MaxTransportMessageBytes = int(^uint(0) >> 1) +// RecordWindow is the number of records without results each stream has room +// for. A client that never exceeds it is never held back by the server's +// window, so its cancel requests take effect once they arrive. +const RecordWindow = 32 + var storePattern = regexp.MustCompile(`^[a-z](?:[a-z0-9]|-[a-z0-9])*$`) var mediaPattern = regexp.MustCompile(`^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$`) @@ -197,9 +203,13 @@ func NativeFailure(started bool, failure *pb.Failure) *pb.NativeEnd { return end } -// ValidateExecuteRequest validates one operation without retaining stream -// state. Callers enforce a fixed Store/kind and consecutive indexes across requests. +// ValidateExecuteRequest validates one record or cancel request without +// retaining stream state. Callers enforce a fixed Store/kind, consecutive +// indexes and cancel requests that name earlier records. func ValidateExecuteRequest(req *pb.ExecuteRequest) error { + if req != nil && req.CancelIndex != 0 { + return validateCancelRequest(req) + } if req == nil || !ValidStoreName(req.StoreName) || req.Index == 0 { return fmt.Errorf("invalid Execute request envelope") } @@ -215,16 +225,50 @@ func ValidateExecuteRequest(req *pb.ExecuteRequest) error { return fmt.Errorf("record index overflows the next ordinal") } default: - if req.Index != 1 { - return fmt.Errorf("Native requires index 1") + if req.Index != 1 || req.TimeoutMicros != 0 { + return fmt.Errorf("Native requires index 1 and no record timeout") } } return nil } +func validateCancelRequest(req *pb.ExecuteRequest) error { + if !ValidStoreName(req.StoreName) || req.CancelIndex == math.MaxUint64 || req.Index != 0 || req.Command != nil || req.TimeoutMicros != 0 { + return fmt.Errorf("a cancel request carries only its Store and cancel_index") + } + if hasUnknown(req.ProtoReflect()) { + return fmt.Errorf("invalid Execute request fields") + } + return nil +} + +// RecordTimeout converts a request's timeout_micros, saturating at the +// longest representable duration. Zero means no record deadline. +func RecordTimeout(micros uint64) time.Duration { + if micros > uint64(math.MaxInt64/int64(time.Microsecond)) { + return math.MaxInt64 + } + return time.Duration(micros) * time.Microsecond +} + +// TimeoutMicros rounds a remaining caller budget up to whole microseconds so a +// record never receives less time than its caller allows. An exhausted budget +// maps to one microsecond, never to the zero that disables the deadline. +func TimeoutMicros(remaining time.Duration) uint64 { + if remaining <= time.Microsecond { + return 1 + } + micros := uint64(remaining / time.Microsecond) + if remaining%time.Microsecond != 0 { + micros++ + } + return micros +} + // ValidateExecuteResponse validates one indexed event and rejects unknown fields -// recursively. Stream consumers enforce the expected event kind, result index sequence, -// and terminal RPC evidence. Missing or unrecognized event variants remain invalid. +// recursively. Stream consumers enforce the expected event kind, at most one +// result per submitted index, and terminal RPC evidence. Missing or +// unrecognized event variants remain invalid. func ValidateExecuteResponse(response *pb.ExecuteResponse) error { if response == nil || response.Index == 0 || response.Index == math.MaxUint64 || hasUnknown(response.ProtoReflect()) { return fmt.Errorf("invalid Execute response envelope") @@ -320,22 +364,29 @@ func hasUnknown(message protoreflect.Message) bool { if len(message.GetUnknown()) != 0 { return true } - unknown := false - message.Range(func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool { - if field.IsList() && field.Message() != nil { - list := value.List() - for i := 0; i < list.Len(); i++ { - if hasUnknown(list.Get(i).Message()) { - unknown = true - return false - } + // Walk populated message fields by descriptor; a Range callback would + // allocate on every validated message. + fields := message.Descriptor().Fields() + for i := range fields.Len() { + field := fields.Get(i) + if field.Message() == nil || !message.Has(field) { + continue + } + value := message.Get(field) + if !field.IsList() { + if hasUnknown(value.Message()) { + return true } - } else if field.Message() != nil { - unknown = hasUnknown(value.Message()) + continue } - return !unknown - }) - return unknown + list := value.List() + for j := range list.Len() { + if hasUnknown(list.Get(j).Message()) { + return true + } + } + } + return false } // ValidateEvent checks known event semantics and fields. diff --git a/api/protocol/protocol_test.go b/api/protocol/protocol_test.go index 2120e49..e175983 100644 --- a/api/protocol/protocol_test.go +++ b/api/protocol/protocol_test.go @@ -1,8 +1,10 @@ package protocol import ( + "math" "strings" "testing" + "time" "unicode/utf8" pb "github.com/batchstream/weir-protocol/api/weir/v1" @@ -113,6 +115,46 @@ func TestExecuteTypedEnvelopeAndUnknownFields(t *testing.T) { } } +func TestCancelRequestCarriesOnlyItsStoreAndIndex(t *testing.T) { + request := &pb.ExecuteRequest{StoreName: "records", CancelIndex: 3} + if err := ValidateExecuteRequest(request); err != nil { + t.Fatal("cancel request rejected", err) + } + invalid := []func(*pb.ExecuteRequest){ + func(r *pb.ExecuteRequest) { r.StoreName = "" }, + func(r *pb.ExecuteRequest) { r.CancelIndex = math.MaxUint64 }, + func(r *pb.ExecuteRequest) { r.ProtoReflect().SetUnknown([]byte{0xf8, 0x07, 1}) }, + } + // Every other field, including one added later, must stay unset. + fields := request.ProtoReflect().Descriptor().Fields() + for i := range fields.Len() { + field := fields.Get(i) + if field.Name() == "store_name" || field.Name() == "cancel_index" { + continue + } + invalid = append(invalid, func(r *pb.ExecuteRequest) { + message := r.ProtoReflect() + switch field.Kind() { + case protoreflect.MessageKind: + message.Set(field, protoreflect.ValueOfMessage(message.NewField(field).Message())) + case protoreflect.StringKind: + message.Set(field, protoreflect.ValueOfString("x")) + case protoreflect.Uint64Kind: + message.Set(field, protoreflect.ValueOfUint64(1)) + default: + t.Fatal("cancel test cannot populate field", field.FullName()) + } + }) + } + for _, mutate := range invalid { + copied := proto.Clone(request).(*pb.ExecuteRequest) + mutate(copied) + if err := ValidateExecuteRequest(copied); err == nil { + t.Fatal("accepted invalid cancel request", copied) + } + } +} + func TestNativeUsesOneIndexedFrame(t *testing.T) { body := &pb.Document{ContentType: "application/vnd.example.native", Data: []byte("command")} native := &pb.NativeRequest{Resource: "records", Request: body} @@ -126,6 +168,10 @@ func TestNativeUsesOneIndexedFrame(t *testing.T) { if err := ValidateExecuteRequest(request); err == nil { t.Fatal("Native record ordinal accepted") } + request.Index, request.TimeoutMicros = 1, 1 + if err := ValidateExecuteRequest(request); err == nil { + t.Fatal("Native record timeout accepted") + } head := &pb.NativeHead{} value := &pb.Event_Head{Head: head} event := &pb.Event{Value: value} @@ -212,3 +258,43 @@ func TestSchemaHasNoMapFields(t *testing.T) { } } } + +func TestRecordTimeoutNeverShortensOrDisablesTheCallerBudget(t *testing.T) { + read := &pb.ReadRequest{Resource: "records/a"} + request := readExecution("records", 1, read) + request.TimeoutMicros = math.MaxUint64 + if err := ValidateExecuteRequest(request); err != nil { + t.Fatal("record timeout rejected", err) + } + cases := []struct { + remaining time.Duration + micros uint64 + }{ + {remaining: -time.Second, micros: 1}, + {remaining: 0, micros: 1}, + {remaining: time.Nanosecond, micros: 1}, + {remaining: time.Microsecond + time.Nanosecond, micros: 2}, + {remaining: 3 * time.Second, micros: 3_000_000}, + {remaining: math.MaxInt64, micros: math.MaxInt64/1000 + 1}, + } + for _, c := range cases { + if got := TimeoutMicros(c.remaining); got != c.micros { + t.Fatalf("TimeoutMicros(%v) = %d, want %d", c.remaining, got, c.micros) + } + } + durations := []struct { + micros uint64 + remaining time.Duration + }{ + {micros: 0, remaining: 0}, + {micros: 3_000_000, remaining: 3 * time.Second}, + {micros: math.MaxInt64 / 1000, remaining: math.MaxInt64 / 1000 * time.Microsecond}, + {micros: math.MaxInt64/1000 + 1, remaining: math.MaxInt64}, + {micros: math.MaxUint64, remaining: math.MaxInt64}, + } + for _, c := range durations { + if got := RecordTimeout(c.micros); got != c.remaining { + t.Fatalf("RecordTimeout(%d) = %v, want %v", c.micros, got, c.remaining) + } + } +} diff --git a/api/weir/v1/contract_test.go b/api/weir/v1/contract_test.go index 52eaee0..8d5754a 100644 --- a/api/weir/v1/contract_test.go +++ b/api/weir/v1/contract_test.go @@ -61,7 +61,7 @@ func TestPublicStoreContract(t *testing.T) { if StoreService_Execute_FullMethodName != "/weir.v1.StoreService/Execute" { t.Fatal("generated public method path changed") } - executeRequest := []fieldContract{{name: "store_name", kind: protoreflect.StringKind}, {name: "index", kind: protoreflect.Uint64Kind}, {name: "command", kind: protoreflect.MessageKind, message: "weir.v1.Command"}} + executeRequest := []fieldContract{{name: "store_name", kind: protoreflect.StringKind}, {name: "index", kind: protoreflect.Uint64Kind}, {name: "command", kind: protoreflect.MessageKind, message: "weir.v1.Command"}, {name: "timeout_micros", kind: protoreflect.Uint64Kind}, {name: "cancel_index", kind: protoreflect.Uint64Kind}} assertFields(t, execute.Input(), executeRequest) executeResponse := []fieldContract{{name: "index", kind: protoreflect.Uint64Kind}, {name: "event", kind: protoreflect.MessageKind, message: "weir.v1.Event"}} assertFields(t, execute.Output(), executeResponse) diff --git a/api/weir/v1/store.pb.go b/api/weir/v1/store.pb.go index c91745b..018038f 100644 --- a/api/weir/v1/store.pb.go +++ b/api/weir/v1/store.pb.go @@ -287,16 +287,36 @@ func (NativeCompletion) EnumDescriptor() ([]byte, []int) { return file_api_weir_v1_store_proto_rawDescGZIP(), []int{3} } -// ExecuteRequest binds one operation to its Store and index. +// ExecuteRequest carries one operation with its Store and index, or cancels an +// earlier record of the stream. type ExecuteRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Store that owns all resources; must remain identical throughout the stream. StoreName string `protobuf:"bytes,1,opt,name=store_name,json=storeName,proto3" json:"store_name,omitempty"` // Consecutive one-based record ordinal. Each Read or Mutate request advances - // it by one. Native always uses 1. UINT64_MAX is invalid. + // it by one. Native always uses 1. UINT64_MAX is invalid. Zero in a cancel + // request. Index uint64 `protobuf:"varint,2,opt,name=index,proto3" json:"index,omitempty"` - // Required operation; its kind must remain identical throughout the stream. - Command *Command `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Operation, required except in a cancel request; its kind must remain + // identical throughout the stream. + Command *Command `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Optional Read or Mutate deadline in microseconds, measured from when the + // server reads the request. Time spent in transport buffers before then is + // not counted, so clients still enforce their own deadline. A record still + // queued when it expires fails with DEADLINE_EXCEEDED, NOT_STARTED for a + // mutation; an executing record reports the evidence its cancellation + // leaves. Zero sets no record deadline. Native requires zero and uses the + // RPC deadline. + TimeoutMicros uint64 `protobuf:"varint,4,opt,name=timeout_micros,json=timeoutMicros,proto3" json:"timeout_micros,omitempty"` + // Cancels the earlier record of this stream with this index instead of + // carrying one. A cancel request sets only store_name and cancel_index, does + // not advance the index and receives no response; the canceled record still + // receives its own result. A queued record fails with CANCELLED, NOT_STARTED + // for a mutation; an executing record reports the evidence its cancellation + // leaves, and a completed record is unaffected. A cancel request takes effect + // once the server reads it, which a record beyond the stream's window can + // delay. UINT64_MAX is invalid. Native streams use RPC cancellation instead. + CancelIndex uint64 `protobuf:"varint,5,opt,name=cancel_index,json=cancelIndex,proto3" json:"cancel_index,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -352,11 +372,26 @@ func (x *ExecuteRequest) GetCommand() *Command { return nil } +func (x *ExecuteRequest) GetTimeoutMicros() uint64 { + if x != nil { + return x.TimeoutMicros + } + return 0 +} + +func (x *ExecuteRequest) GetCancelIndex() uint64 { + if x != nil { + return x.CancelIndex + } + return 0 +} + // ExecuteResponse carries one result or streaming event. type ExecuteResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // One-based input record ordinal; record results arrive in input order. - // Every input record has exactly one result. Native events all use 1. + // One-based input record ordinal; record results arrive in completion order. + // An index receives at most one result, and every record receives one + // unless the RPC fails first. Native events all use 1. Index uint64 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` // Required typed event; it must match the stream's operation kind. Event *Event `protobuf:"bytes,2,opt,name=event,proto3" json:"event,omitempty"` @@ -1533,12 +1568,14 @@ var File_api_weir_v1_store_proto protoreflect.FileDescriptor const file_api_weir_v1_store_proto_rawDesc = "" + "\n" + - "\x17api/weir/v1/store.proto\x12\aweir.v1\"q\n" + + "\x17api/weir/v1/store.proto\x12\aweir.v1\"\xbb\x01\n" + "\x0eExecuteRequest\x12\x1d\n" + "\n" + "store_name\x18\x01 \x01(\tR\tstoreName\x12\x14\n" + "\x05index\x18\x02 \x01(\x04R\x05index\x12*\n" + - "\acommand\x18\x03 \x01(\v2\x10.weir.v1.CommandR\acommand\"M\n" + + "\acommand\x18\x03 \x01(\v2\x10.weir.v1.CommandR\acommand\x12%\n" + + "\x0etimeout_micros\x18\x04 \x01(\x04R\rtimeoutMicros\x12!\n" + + "\fcancel_index\x18\x05 \x01(\x04R\vcancelIndex\"M\n" + "\x0fExecuteResponse\x12\x14\n" + "\x05index\x18\x01 \x01(\x04R\x05index\x12$\n" + "\x05event\x18\x02 \x01(\v2\x0e.weir.v1.EventR\x05event\"\xa6\x01\n" + diff --git a/api/weir/v1/store.proto b/api/weir/v1/store.proto index 5e03ddf..22ea313 100644 --- a/api/weir/v1/store.proto +++ b/api/weir/v1/store.proto @@ -12,8 +12,19 @@ option go_package = "github.com/batchstream/weir-protocol/api/weir/v1;weirv1"; service StoreService { // Execute accepts requests and emits indexed results incrementally. // A stream selects one Store and operation kind. Each Read or Mutate request - // carries one record with a consecutive one-based index. It is validated - // before execution; earlier records may have effects before a later one fails. + // carries one record with a consecutive one-based index and receives at most + // one result, in completion order; consumers match results by index. A + // record the server cannot admit, such as one its adapter rejects, receives + // its own failed result, NOT_STARTED for a mutation, and the stream + // continues. A request that fails validation, changes the Store or kind, + // breaks the index sequence or cancels a record not yet sent fails the RPC + // with INVALID_ARGUMENT after the results of the records admitted before it. + // A record without a result when the RPC fails may still have effects. A + // cancel request cancels one earlier record without ending the stream. + // Each stream has room for 32 records without results, whatever the Store + // queue holds. A client that never has more than 32 records without results + // is never held back by the server's window, so its cancel requests take + // effect once they arrive; a further record waits until a result frees room. // Mutations to the same resource preserve input order across requests, including // after item failures. Different resources may execute concurrently. A stream // is not a transaction; ordering across streams follows database semantics. @@ -23,23 +34,46 @@ service StoreService { rpc Execute(stream ExecuteRequest) returns (stream ExecuteResponse); } -// ExecuteRequest binds one operation to its Store and index. +// ExecuteRequest carries one operation with its Store and index, or cancels an +// earlier record of the stream. message ExecuteRequest { // Store that owns all resources; must remain identical throughout the stream. string store_name = 1; // Consecutive one-based record ordinal. Each Read or Mutate request advances - // it by one. Native always uses 1. UINT64_MAX is invalid. + // it by one. Native always uses 1. UINT64_MAX is invalid. Zero in a cancel + // request. uint64 index = 2; - // Required operation; its kind must remain identical throughout the stream. + // Operation, required except in a cancel request; its kind must remain + // identical throughout the stream. Command command = 3; + + // Optional Read or Mutate deadline in microseconds, measured from when the + // server reads the request. Time spent in transport buffers before then is + // not counted, so clients still enforce their own deadline. A record still + // queued when it expires fails with DEADLINE_EXCEEDED, NOT_STARTED for a + // mutation; an executing record reports the evidence its cancellation + // leaves. Zero sets no record deadline. Native requires zero and uses the + // RPC deadline. + uint64 timeout_micros = 4; + + // Cancels the earlier record of this stream with this index instead of + // carrying one. A cancel request sets only store_name and cancel_index, does + // not advance the index and receives no response; the canceled record still + // receives its own result. A queued record fails with CANCELLED, NOT_STARTED + // for a mutation; an executing record reports the evidence its cancellation + // leaves, and a completed record is unaffected. A cancel request takes effect + // once the server reads it, which a record beyond the stream's window can + // delay. UINT64_MAX is invalid. Native streams use RPC cancellation instead. + uint64 cancel_index = 5; } // ExecuteResponse carries one result or streaming event. message ExecuteResponse { - // One-based input record ordinal; record results arrive in input order. - // Every input record has exactly one result. Native events all use 1. + // One-based input record ordinal; record results arrive in completion order. + // An index receives at most one result, and every record receives one + // unless the RPC fails first. Native events all use 1. uint64 index = 1; // Required typed event; it must match the stream's operation kind. diff --git a/api/weir/v1/store_grpc.pb.go b/api/weir/v1/store_grpc.pb.go index c7b5a45..0c54a6e 100644 --- a/api/weir/v1/store_grpc.pb.go +++ b/api/weir/v1/store_grpc.pb.go @@ -29,8 +29,19 @@ const ( type StoreServiceClient interface { // Execute accepts requests and emits indexed results incrementally. // A stream selects one Store and operation kind. Each Read or Mutate request - // carries one record with a consecutive one-based index. It is validated - // before execution; earlier records may have effects before a later one fails. + // carries one record with a consecutive one-based index and receives at most + // one result, in completion order; consumers match results by index. A + // record the server cannot admit, such as one its adapter rejects, receives + // its own failed result, NOT_STARTED for a mutation, and the stream + // continues. A request that fails validation, changes the Store or kind, + // breaks the index sequence or cancels a record not yet sent fails the RPC + // with INVALID_ARGUMENT after the results of the records admitted before it. + // A record without a result when the RPC fails may still have effects. A + // cancel request cancels one earlier record without ending the stream. + // Each stream has room for 32 records without results, whatever the Store + // queue holds. A client that never has more than 32 records without results + // is never held back by the server's window, so its cancel requests take + // effect once they arrive; a further record waits until a result frees room. // Mutations to the same resource preserve input order across requests, including // after item failures. Different resources may execute concurrently. A stream // is not a transaction; ordering across streams follows database semantics. @@ -66,8 +77,19 @@ func (c *storeServiceClient) Execute(ctx context.Context, opts ...grpc.CallOptio type StoreServiceServer interface { // Execute accepts requests and emits indexed results incrementally. // A stream selects one Store and operation kind. Each Read or Mutate request - // carries one record with a consecutive one-based index. It is validated - // before execution; earlier records may have effects before a later one fails. + // carries one record with a consecutive one-based index and receives at most + // one result, in completion order; consumers match results by index. A + // record the server cannot admit, such as one its adapter rejects, receives + // its own failed result, NOT_STARTED for a mutation, and the stream + // continues. A request that fails validation, changes the Store or kind, + // breaks the index sequence or cancels a record not yet sent fails the RPC + // with INVALID_ARGUMENT after the results of the records admitted before it. + // A record without a result when the RPC fails may still have effects. A + // cancel request cancels one earlier record without ending the stream. + // Each stream has room for 32 records without results, whatever the Store + // queue holds. A client that never has more than 32 records without results + // is never held back by the server's window, so its cancel requests take + // effect once they arrive; a further record waits until a result frees room. // Mutations to the same resource preserve input order across requests, including // after item failures. Different resources may execute concurrently. A stream // is not a transaction; ordering across streams follows database semantics.