Skip to content

Commit

Permalink
feat: add retry metadata to fsm decl (#1554)
Browse files Browse the repository at this point in the history
Changes:
- Extract retries metadata declared for a FSM. The idea is that this
will be the default retry policy for each transition, but that is out of
scope for this PR.
- Traversing the ast graph now provides a stack of nodes rather than
just the current node (so we can derive info based on parent nodes,
rather than maintaining that state with special cases)
- removed `currentVerb` from `parserContext` as this special case is no
longer needed

Add retries to a FSM like this:
```go
// The payment FSM.
//
//ftl:retry 10 5s 10m
var paymentFSM = ftl.FSM("payment",
	ftl.Start(Created),
	ftl.Start(Paid),
	ftl.Transition(Created, Paid),
	ftl.Transition(Created, Failed),
	ftl.Transition(Paid, Completed),
)
```
In the schema it looks like this:
```
fsm payment
   +retry 10 5s 10m
{
   start fsm.created
   start fsm.paid
   transition fsm.created to fsm.paid
   transition fsm.created to fsm.failed
   transition fsm.paid to fsm.completed
}
  • Loading branch information
matt2e authored May 27, 2024
1 parent 12db5c3 commit 9993dee
Show file tree
Hide file tree
Showing 9 changed files with 718 additions and 820 deletions.
841 changes: 427 additions & 414 deletions backend/protos/xyz/block/ftl/v1/schema/schema.pb.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/protos/xyz/block/ftl/v1/schema/schema.proto
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ message FSM {
string name = 3;
repeated Ref start = 4;
repeated FSMTransition transitions = 5;
repeated Metadata metadata = 6;
}

message FSMTransition {
Expand Down
14 changes: 13 additions & 1 deletion backend/schema/fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type FSM struct {
Name string `parser:"'fsm' @Ident '{'" protobuf:"3"`
Start []*Ref `parser:"('start' @@)*" protobuf:"4"` // Start states.
Transitions []*FSMTransition `parser:"('transition' @@)* '}'" protobuf:"5"`
Metadata []Metadata `parser:"@@*" protobuf:"6"`
}

func FSMFromProto(pb *schemapb.FSM) *FSM {
Expand All @@ -26,6 +27,7 @@ func FSMFromProto(pb *schemapb.FSM) *FSM {
Name: pb.Name,
Start: slices.Map(pb.Start, RefFromProto),
Transitions: slices.Map(pb.Transitions, FSMTransitionFromProto),
Metadata: metadataListToSchema(pb.Metadata),
}
}

Expand Down Expand Up @@ -62,7 +64,13 @@ func (f *FSM) schemaSymbol() {}

func (f *FSM) String() string {
w := &strings.Builder{}
fmt.Fprintf(w, "fsm %s {\n", f.Name)
if len(f.Metadata) == 0 {
fmt.Fprintf(w, "fsm %s {\n", f.Name)
} else {
fmt.Fprintf(w, "fsm %s", f.Name)
fmt.Fprint(w, indent(encodeMetadata(f.Metadata)))
fmt.Fprintf(w, "\n{\n")
}
for _, s := range f.Start {
fmt.Fprintf(w, " start %s\n", s)
}
Expand All @@ -83,6 +91,7 @@ func (f *FSM) ToProto() protoreflect.ProtoMessage {
Transitions: slices.Map(f.Transitions, func(t *FSMTransition) *schemapb.FSMTransition {
return t.ToProto().(*schemapb.FSMTransition) //nolint: forcetypeassert
}),
Metadata: metadataListToProto(f.Metadata),
}
}

Expand All @@ -94,6 +103,9 @@ func (f *FSM) schemaChildren() []Node {
for _, t := range f.Transitions {
out = append(out, t)
}
for _, m := range f.Metadata {
out = append(out, m)
}
return out
}

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

case *schemapb.Metadata_Retry:
var count *int
if s.Retry.Count != nil {
countValue := int(*s.Retry.Count)
count = &countValue
}
return &MetadataRetry{
Pos: posFromProto(s.Retry.Pos),
Count: count,
MinBackoff: s.Retry.MinBackoff,
MaxBackoff: s.Retry.MaxBackoff,
}

default:
panic(fmt.Sprintf("unhandled metadata type: %T", s))
}
Expand Down
6 changes: 6 additions & 0 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.

109 changes: 75 additions & 34 deletions go-runtime/compile/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,17 @@ func ExtractModuleSchema(dir string, sch *schema.Schema) (optional.Option[ParseR
return optional.None[ParseResult](), err
}
for _, file := range pkg.Syntax {
err := goast.Visit(file, func(node ast.Node, next func() error) (err error) {
err := goast.Visit(file, func(stack []ast.Node, next func() error) (err error) {
node := stack[len(stack)-1]
switch node := node.(type) {
case *ast.CallExpr:
visitCallExpr(pctx, node)
visitCallExpr(pctx, node, stack)

case *ast.File:
visitFile(pctx, node)

case *ast.FuncDecl:
verb := visitFuncDecl(pctx, node)
pctx.activeVerb = verb
err = next()
if err != nil {
return err
}
pctx.activeVerb = nil
return nil
visitFuncDecl(pctx, node)

case *ast.GenDecl:
visitGenDecl(pctx, node)
Expand Down Expand Up @@ -200,8 +194,8 @@ func ExtractModuleSchema(dir string, sch *schema.Schema) (optional.Option[ParseR
// - This get's filled in with the next pass
func extractTypeDecls(pctx *parseContext) error {
for _, file := range pctx.pkg.Syntax {
err := goast.Visit(file, func(aNode ast.Node, next func() error) (err error) {
node, ok := aNode.(*ast.GenDecl)
err := goast.Visit(file, func(stack []ast.Node, next func() error) (err error) {
node, ok := (stack[len(stack)-1]).(*ast.GenDecl)
if !ok || node.Tok != token.TYPE {
return next()
}
Expand Down Expand Up @@ -243,7 +237,7 @@ func extractTypeDeclsForNode(pctx *parseContext, node *ast.GenDecl) {
case *types.Basic:
enum := &schema.Enum{
Pos: goPosToSchemaPos(node.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Name: strcase.ToUpperCamel(t.Name.Name),
Type: nil, //TODO: explain
Export: dir.IsExported(),
Expand All @@ -270,14 +264,14 @@ func extractTypeDeclsForNode(pctx *parseContext, node *ast.GenDecl) {

enum := &schema.Enum{
Pos: goPosToSchemaPos(node.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Name: strcase.ToUpperCamel(t.Name.Name),
Export: dir.IsExported(),
}
if typ, ok := typ.(*types.Interface); ok {
if iTyp, ok := typ.(*types.Interface); ok {
pctx.nativeNames[enum] = nativeName
pctx.module.Decls = append(pctx.module.Decls, enum)
pctx.enumInterfaces[t.Name.Name] = typ
pctx.enumInterfaces[t.Name.Name] = iTyp
} else {
pctx.errors.add(errorf(node, "expected interface for type enum but got %q", typ))
}
Expand All @@ -286,7 +280,7 @@ func extractTypeDeclsForNode(pctx *parseContext, node *ast.GenDecl) {
case *directiveTypeAlias:
alias := &schema.TypeAlias{
Pos: goPosToSchemaPos(node.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Name: strcase.ToUpperCamel(t.Name.Name),
Export: dir.IsExported(),
Type: nil, //TODO: explain
Expand All @@ -306,29 +300,48 @@ func extractTypeDeclsForNode(pctx *parseContext, node *ast.GenDecl) {
}
}

func visitCallExpr(pctx *parseContext, node *ast.CallExpr) {
func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
_, fn := deref[*types.Func](pctx.pkg, node.Fun)
if fn == nil {
return
}
switch fn.FullName() {
case ftlCallFuncPath:
parseCall(pctx, node)
parseCall(pctx, node, stack)

case ftlConfigFuncPath, ftlSecretFuncPath:
// Secret/config declaration: ftl.Config[<type>](<name>)
parseConfigDecl(pctx, node, fn)

case ftlFSMFuncPath:
parseFSMDecl(pctx, node)
parseFSMDecl(pctx, node, stack)

case ftlPostgresDBFuncPath:
parseDatabaseDecl(pctx, node, schema.PostgresDatabaseType)
}
}

func parseCall(pctx *parseContext, node *ast.CallExpr) {
if pctx.activeVerb == nil {
func parseCall(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
var activeFuncDecl *ast.FuncDecl
for i := len(stack) - 1; i >= 0; i-- {
if found, ok := stack[i].(*ast.FuncDecl); ok {
activeFuncDecl = found
break
}
// use element
}
if activeFuncDecl == nil {
return
}
expectedVerbName := strcase.ToLowerCamel(activeFuncDecl.Name.Name)
var activeVerb *schema.Verb
for _, decl := range pctx.module.Decls {
if aVerb, ok := decl.(*schema.Verb); ok && aVerb.Name == expectedVerbName {
activeVerb = aVerb
break
}
}
if activeVerb == nil {
return
}
if len(node.Args) != 3 {
Expand All @@ -348,7 +361,7 @@ func parseCall(pctx *parseContext, node *ast.CallExpr) {
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module%s", suffix))
return
}
pctx.activeVerb.AddCall(ref)
activeVerb.AddCall(ref)
}

func parseSelectorRef(node ast.Expr) *schema.Ref {
Expand Down Expand Up @@ -384,7 +397,7 @@ func parseVerbRef(pctx *parseContext, node ast.Expr) *schema.Ref {
}
}

func parseFSMDecl(pctx *parseContext, node *ast.CallExpr) {
func parseFSMDecl(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
var literal *ast.BasicLit
if len(node.Args) > 0 {
literal, _ = node.Args[0].(*ast.BasicLit)
Expand All @@ -404,8 +417,9 @@ func parseFSMDecl(pctx *parseContext, node *ast.CallExpr) {
}

fsm := &schema.FSM{
Pos: goPosToSchemaPos(node.Pos()),
Name: name,
Pos: goPosToSchemaPos(node.Pos()),
Name: name,
Metadata: []schema.Metadata{},
}
pctx.module.Decls = append(pctx.module.Decls, fsm)

Expand All @@ -422,6 +436,34 @@ func parseFSMDecl(pctx *parseContext, node *ast.CallExpr) {
}
parseFSMTransition(pctx, call, fn, fsm)
}

// find variable declaration that we are currently in so we can look for attached directives
var variableDecl *ast.GenDecl
for i := len(stack) - 1; i >= 0; i-- {
if decl, ok := stack[i].(*ast.GenDecl); ok && decl.Tok == token.VAR {
variableDecl = decl
break
}
}
if variableDecl == nil || variableDecl.Doc == nil {
return
}
directives, schemaErr := parseDirectives(node, fset, variableDecl.Doc)
if schemaErr != nil {
pctx.errors.add(schemaErr)
}
for _, dir := range directives {
if retryDir, ok := dir.(*directiveRetry); ok {
fsm.Metadata = append(fsm.Metadata, &schema.MetadataRetry{
Pos: retryDir.Pos,
Count: retryDir.Count,
MinBackoff: retryDir.MinBackoff,
MaxBackoff: retryDir.MaxBackoff,
})
} else {
pctx.errors.add(errorf(node, "unexpected directive attached for FSM: %T", dir))
}
}
}

// Parse a Start or Transition call in an FSM declaration and add it to the FSM.
Expand Down Expand Up @@ -565,7 +607,7 @@ func visitFile(pctx *parseContext, node *ast.File) {
if node.Doc == nil {
return
}
pctx.module.Comments = visitComments(node.Doc)
pctx.module.Comments = parseComments(node.Doc)
}

func isType[T types.Type](t types.Type) bool {
Expand Down Expand Up @@ -753,7 +795,7 @@ func maybeVisitTypeEnumVariant(pctx *parseContext, node *ast.GenDecl, directives

enumVariant := &schema.EnumVariant{
Pos: goPosToSchemaPos(node.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Name: strcase.ToUpperCamel(t.Name.Name),
}

Expand Down Expand Up @@ -918,7 +960,7 @@ func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) {
if value, ok := visitConst(pctx, c).Get(); ok {
variant := &schema.EnumVariant{
Pos: goPosToSchemaPos(c.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Name: strcase.ToUpperCamel(c.Id()),
Value: value,
}
Expand Down Expand Up @@ -1056,7 +1098,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) {
}
verb = &schema.Verb{
Pos: goPosToSchemaPos(node.Pos()),
Comments: visitComments(node.Doc),
Comments: parseComments(node.Doc),
Export: isExported,
Name: strcase.ToLowerCamel(node.Name.Name),
Request: reqV,
Expand All @@ -1068,7 +1110,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) {
return verb
}

func visitComments(doc *ast.CommentGroup) []string {
func parseComments(doc *ast.CommentGroup) []string {
comments := []string{}
if doc := doc.Text(); doc != "" {
comments = strings.Split(strings.TrimSpace(doc), "\n")
Expand Down Expand Up @@ -1160,11 +1202,11 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type, isExported
switch path := path[i].(type) {
case *ast.TypeSpec:
if path.Doc != nil {
out.Comments = visitComments(path.Doc)
out.Comments = parseComments(path.Doc)
}
case *ast.GenDecl:
if path.Doc != nil {
out.Comments = visitComments(path.Doc)
out.Comments = parseComments(path.Doc)
}
}
}
Expand Down Expand Up @@ -1463,7 +1505,6 @@ type parseContext struct {
module *schema.Module
nativeNames NativeNames
enumInterfaces enumInterfaces
activeVerb *schema.Verb
errors errorSet
schema *schema.Schema
}
Expand Down
4 changes: 3 additions & 1 deletion go-runtime/compile/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@ func TestExtractModuleSchemaFSM(t *testing.T) {
assert.Equal(t, r.MustGet().Errors, nil, "expected no schema errors")
actual := schema.Normalise(r.MustGet().Module)
expected := `module fsm {
fsm payment {
fsm payment
+retry 10 5s 10m
{
start fsm.created
start fsm.paid
transition fsm.created to fsm.paid
Expand Down
2 changes: 2 additions & 0 deletions go-runtime/compile/testdata/fsm/fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
)

// The payment FSM.
//
//ftl:retry 10 5s 10m
var paymentFSM = ftl.FSM("payment",
ftl.Start(Created),
ftl.Start(Paid),
Expand Down
Loading

0 comments on commit 9993dee

Please sign in to comment.