Skip to content

Commit

Permalink
feat: schema support for cron metadata (#1216)
Browse files Browse the repository at this point in the history
- detects verbs with cron annotations
- language support:
    - full support in go
- verbs don't need `//ftl:export` when annotated with `//ftl:cron`
- rudimentary and untested support in kotlin (enough to define `@Cron("*
* * * *)`)
  • Loading branch information
matt2e authored Apr 10, 2024
1 parent 105ee80 commit 323fed6
Show file tree
Hide file tree
Showing 21 changed files with 704 additions and 391 deletions.
823 changes: 460 additions & 363 deletions backend/protos/xyz/block/ftl/v1/schema/schema.pb.go

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions backend/protos/xyz/block/ftl/v1/schema/schema.proto
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,9 @@ message Metadata {
oneof value {
MetadataCalls calls = 1;
MetadataIngress ingress = 2;
MetadataDatabases databases = 3;
MetadataAlias alias = 4;
MetadataCronJob cronJob = 3;
MetadataDatabases databases = 4;
MetadataAlias alias = 5;
}
}

Expand All @@ -146,6 +147,11 @@ message MetadataCalls {
repeated Ref calls = 2;
}

message MetadataCronJob {
optional Position pos = 1;
string cron = 2;
}

message MetadataDatabases {
optional Position pos = 1;
repeated Ref calls = 2;
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func (d *Data) Monomorphise(ref *Ref) (*Data, error) {

case *Any, *Bool, *Bytes, *Data, *Ref, *Database, Decl, *Float,
IngressPathComponent, *IngressPathLiteral, *IngressPathParameter, *Int,
Metadata, *MetadataCalls, *MetadataDatabases, *MetadataIngress,
Metadata, *MetadataCalls, *MetadataDatabases, *MetadataIngress, *MetadataCronJob,
*MetadataAlias, *Module, *Schema, *String, *Time, Type, *TypeParameter,
*Unit, *Verb, *Enum, *EnumVariant,
Value, *IntValue, *StringValue, Symbol, Named:
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func nodeToJSSchema(node Node, refs map[RefKey]*Ref) *jsonschema.Schema {

case Decl, *Field, Metadata, *MetadataCalls, *MetadataDatabases, *MetadataIngress,
*MetadataAlias, IngressPathComponent, *IngressPathLiteral, *IngressPathParameter, *Module,
*Schema, Type, *Database, *Verb, *EnumVariant,
*Schema, Type, *Database, *Verb, *EnumVariant, *MetadataCronJob,
Value, *StringValue, *IntValue, *Config, *Secret, Symbol, Named:
panic(fmt.Sprintf("unsupported node type %T", node))

Expand Down
35 changes: 35 additions & 0 deletions backend/schema/metadatacronjob.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package schema

import (
"fmt"

"google.golang.org/protobuf/proto"

schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema"
)

type MetadataCronJob struct {
Pos Position `parser:"" protobuf:"1,optional"`

Cron string `parser:"'+' 'cron' Whitespace @((' ' | Number | '-' | '/' | '*' | ',')+)" protobuf:"2"`
}

var _ Metadata = (*MetadataCronJob)(nil)

func (m *MetadataCronJob) Position() Position { return m.Pos }
func (m *MetadataCronJob) String() string {
return fmt.Sprintf("+cron %s", m.Cron)
}

func (m *MetadataCronJob) schemaChildren() []Node {
return nil
}

func (*MetadataCronJob) schemaMetadata() {}

func (m *MetadataCronJob) ToProto() proto.Message {
return &schemapb.MetadataCronJob{
Pos: posToProto(m.Pos),
Cron: m.Cron,
}
}
4 changes: 4 additions & 0 deletions backend/schema/normalise.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func Normalise[T Node](n T) T {
c.Pos = zero

case *Unit:
c.Unit = true
c.Pos = zero

case *Schema:
Expand Down Expand Up @@ -122,6 +123,9 @@ func Normalise[T Node](n T) T {
case *IngressPathParameter:
c.Pos = zero

case *MetadataCronJob:
c.Pos = zero

case *Config:
c.Pos = zero
c.Type = Normalise(c.Type)
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var (
&Ref{},
}
typeUnion = append(nonOptionalTypeUnion, &Optional{})
metadataUnion = []Metadata{&MetadataCalls{}, &MetadataIngress{}, &MetadataDatabases{}, &MetadataAlias{}}
metadataUnion = []Metadata{&MetadataCalls{}, &MetadataIngress{}, &MetadataCronJob{}, &MetadataDatabases{}, &MetadataAlias{}}
ingressUnion = []IngressPathComponent{&IngressPathLiteral{}, &IngressPathParameter{}}
valueUnion = []Value{&StringValue{}, &IntValue{}}

Expand Down
6 changes: 6 additions & 0 deletions backend/schema/protobuf_dec.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ func metadataToSchema(s *schemapb.Metadata) Metadata {
Path: ingressPathComponentListToSchema(s.Ingress.Path),
}

case *schemapb.Metadata_CronJob:
return &MetadataCronJob{
Pos: posFromProto(s.CronJob.Pos),
Cron: s.CronJob.Cron,
}

case *schemapb.Metadata_Alias:
return &MetadataAlias{
Pos: posFromProto(s.Alias.Pos),
Expand Down
3 changes: 3 additions & 0 deletions backend/schema/protobuf_enc.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ func metadataListToProto(nodes []Metadata) []*schemapb.Metadata {
case *MetadataIngress:
v = &schemapb.Metadata_Ingress{Ingress: n.ToProto().(*schemapb.MetadataIngress)}

case *MetadataCronJob:
v = &schemapb.Metadata_CronJob{CronJob: n.ToProto().(*schemapb.MetadataCronJob)}

case *MetadataAlias:
v = &schemapb.Metadata_Alias{Alias: n.ToProto().(*schemapb.MetadataAlias)}

Expand Down
18 changes: 18 additions & 0 deletions backend/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ module todo {
verb destroy(builtin.HttpRequest<todo.DestroyRequest>) builtin.HttpResponse<todo.DestroyResponse, String>
+ingress http GET /todo/destroy/{name}
verb scheduled(Unit) Unit
+cron */10 * * 1-10,11-31 * * *
}
module foo {
Expand Down Expand Up @@ -136,6 +139,10 @@ Module
IngressPathLiteral
IngressPathLiteral
IngressPathParameter
Verb
Unit
Unit
MetadataCronJob
`
actual := &strings.Builder{}
i := 0
Expand Down Expand Up @@ -389,6 +396,8 @@ module todo {
+calls todo.destroy +database calls todo.testdb
verb destroy(builtin.HttpRequest<todo.DestroyRequest>) builtin.HttpResponse<todo.DestroyResponse, String>
+ingress http GET /todo/destroy/{name}
verb scheduled(Unit) Unit
+cron */10 * * 1-10,11-31 * * *
}
`
actual, err := ParseModuleString("", input)
Expand Down Expand Up @@ -484,6 +493,15 @@ var testSchema = MustValidate(&Schema{
},
},
},
&Verb{Name: "scheduled",
Request: &Unit{Unit: true},
Response: &Unit{Unit: true},
Metadata: []Metadata{
&MetadataCronJob{
Cron: "*/10 * * 1-10,11-31 * * *",
},
},
},
},
},
{
Expand Down
19 changes: 16 additions & 3 deletions backend/schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/alecthomas/types/optional"
"golang.org/x/exp/maps"

"github.com/TBD54566975/ftl/internal/cron"
"github.com/TBD54566975/ftl/internal/errors"
dc "github.com/TBD54566975/ftl/internal/reflect"
)
Expand Down Expand Up @@ -173,7 +174,7 @@ func ValidateModuleInSchema(schema *Schema, m optional.Option[*Module]) (*Schema

case *Array, *Bool, *Bytes, *Data, *Database, Decl, *Field, *Float,
IngressPathComponent, *IngressPathLiteral, *IngressPathParameter,
*Int, *Map, Metadata, *MetadataCalls, *MetadataDatabases,
*Int, *Map, Metadata, *MetadataCalls, *MetadataDatabases, *MetadataCronJob,
*MetadataIngress, *MetadataAlias, *Module, *Optional, *Schema,
*String, *Time, Type, *Unit, *Any, *TypeParameter, *EnumVariant,
Value, *IntValue, *StringValue, *Config, *Secret, Symbol, Named:
Expand Down Expand Up @@ -304,7 +305,7 @@ func ValidateModule(module *Module) error {

case *Array, *Bool, *Database, *Float, *Int,
*Time, *Map, *Module, *Schema, *String, *Bytes,
*MetadataCalls, *MetadataDatabases, *MetadataIngress, *MetadataAlias,
*MetadataCalls, *MetadataDatabases, *MetadataIngress, *MetadataCronJob, *MetadataAlias,
IngressPathComponent, *IngressPathLiteral, *IngressPathParameter, *Optional,
*Unit, *Any, *TypeParameter, *Enum, *EnumVariant, *IntValue, *StringValue:

Expand Down Expand Up @@ -452,7 +453,15 @@ func errorf(pos interface{ Position() Position }, format string, args ...interfa

func validateVerbMetadata(scopes Scopes, n *Verb) (merr []error) {
// Validate metadata
metadataTypes := map[reflect.Type]bool{}
for _, md := range n.Metadata {
reflected := reflect.TypeOf(md)
if _, seen := metadataTypes[reflected]; seen {
merr = append(merr, errorf(md, "verb can not have multiple instances of %s", strings.ToLower(strings.TrimPrefix(reflected.String(), "*schema.Metadata"))))
continue
}
metadataTypes[reflected] = true

switch md := md.(type) {
case *MetadataIngress:
reqBodyType, reqBody, errs := validateIngressRequestOrResponse(scopes, n, "request", n.Request)
Expand All @@ -474,7 +483,11 @@ func validateVerbMetadata(scopes Scopes, n *Verb) (merr []error) {
case *IngressPathLiteral:
}
}

case *MetadataCronJob:
_, err := cron.Parse(md.Cron)
if err != nil {
merr = append(merr, err)
}
case *MetadataCalls, *MetadataDatabases, *MetadataAlias:
}
}
Expand Down
25 changes: 25 additions & 0 deletions backend/schema/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ func TestValidate(t *testing.T) {
}
`,
},
{name: "DoubleCron",
schema: `
module one {
verb cronjob(Unit) Unit
+cron * */2 0-23/2,4-5 * * * *
+cron * * * * * * *
}
`,
errs: []string{
"5:7-7: verb can not have multiple instances of cronjob",
},
},
{name: "DoubleIngress",
schema: `
module one {
data Data {}
verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty>
+ingress http GET /one
+ingress http GET /two
}
`,
errs: []string{
"6:10-10: verb can not have multiple instances of ingress",
},
},
}

for _, test := range tests {
Expand Down
9 changes: 9 additions & 0 deletions backend/schema/verb.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ func (v *Verb) GetMetadataIngress() optional.Option[*MetadataIngress] {
return optional.None[*MetadataIngress]()
}

func (v *Verb) GetMetadataCronJob() optional.Option[*MetadataCronJob] {
for _, m := range v.Metadata {
if m, ok := m.(*MetadataCronJob); ok {
return optional.Some(m)
}
}
return optional.None[*MetadataCronJob]()
}

func (v *Verb) ToProto() proto.Message {
return &schemapb.Verb{
Pos: posToProto(v.Pos),
Expand Down
58 changes: 54 additions & 4 deletions frontend/src/protos/xyz/block/ftl/v1/schema/schema_pb.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion go-runtime/compile/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,24 @@ func (d *directiveIngress) String() string {
return w.String()
}

type directiveCronJob struct {
Pos schema.Position

Cron string `parser:"'cron' Whitespace @((' ' | Number | '-' | '/' | '*' | ',')+)"`
}

func (*directiveCronJob) directive() {}

func (d *directiveCronJob) String() string {
return fmt.Sprintf("cron %s", d.Cron)
}

var directiveParser = participle.MustBuild[directiveWrapper](
participle.Lexer(schema.Lexer),
participle.Elide("Whitespace"),
participle.Unquote(),
participle.UseLookahead(2),
participle.Union[directive](&directiveExport{}, &directiveIngress{}),
participle.Union[directive](&directiveExport{}, &directiveIngress{}, &directiveCronJob{}),
participle.Union[schema.IngressPathComponent](&schema.IngressPathLiteral{}, &schema.IngressPathParameter{}),
)

Expand Down
Loading

0 comments on commit 323fed6

Please sign in to comment.