1
0
mirror of https://github.com/MontFerret/ferret.git synced 2026-06-20 01:17:53 +02:00

feat: add support for WAITFOR VALUE WHEN clause in wait predicates (#939)

This commit is contained in:
Tim Voronov
2026-05-24 20:03:08 -04:00
committed by GitHub
parent be0a60786a
commit 2eee292793
16 changed files with 2379 additions and 1980 deletions
+8
View File
@@ -102,6 +102,14 @@ func resolveWaitPredicateMode(hasValue, hasExists, hasNot bool) waitForPredicate
return waitForPredicateModeBool
}
func waitPredicateWhenExpression(ctx fql.IWaitForPredicateWhenClauseContext) fql.IExpressionContext {
if ctx == nil {
return nil
}
return ctx.Expression()
}
func literalFromExpression(ctx fql.IExpressionContext) fql.ILiteralContext {
if ctx == nil {
return nil
+27 -1
View File
@@ -18,6 +18,10 @@ type waitPredicatePollState struct {
const waitForDefaultEveryMs = 100
func (c *WaitCompiler) tryCompileWaitPredicateFastPath(config waitPredicateCompileConfig) (bytecode.Operand, bool) {
if config.whenExpr != nil {
return bytecode.NoopOperand, false
}
switch config.mode {
case waitForPredicateModeBool:
truth, ok := literalTruthinessFromExpression(config.predExpr)
@@ -157,7 +161,16 @@ func (c *WaitCompiler) emitWaitPredicatePollIteration(
) bytecode.Operand {
valueReg := c.exprs.Compile(config.predExpr)
condReg := c.emitWaitPredicateCondition(config.mode, valueReg)
c.ctx.Program.Emitter.EmitJumpIfTrue(condReg, successLabel)
if config.whenExpr == nil {
c.ctx.Program.Emitter.EmitJumpIfTrue(condReg, successLabel)
} else {
retryLabel := c.ctx.Program.Emitter.NewLabel()
c.ctx.Program.Emitter.EmitJumpIfFalse(condReg, retryLabel)
whenReg := c.emitWaitPredicateWhenCondition(config, valueReg)
c.ctx.Program.Emitter.EmitJumpIfTrue(whenReg, successLabel)
c.ctx.Program.Emitter.MarkLabel(retryLabel)
}
elapsedReg := c.emitWaitPredicateTimeoutCheck(config.timeoutReg, state.startReg, state.unitReg, timeoutLabel)
sleepIntervalReg := c.prepareWaitSleepInterval(config, state.pollReg)
@@ -175,6 +188,19 @@ func (c *WaitCompiler) emitWaitPredicatePollIteration(
return valueReg
}
func (c *WaitCompiler) emitWaitPredicateWhenCondition(config waitPredicateCompileConfig, valueReg bytecode.Operand) bytecode.Operand {
if config.whenExpr == nil {
return bytecode.NoopOperand
}
c.ctx.Function.Symbols.EnterScope()
defer c.ctx.Function.Symbols.ExitScope()
c.ctx.Function.Symbols.AssignLocal(core.PseudoVariable, core.TypeUnknown, valueReg)
return c.exprs.CompileWithImplicitCurrent(config.whenExpr)
}
func (c *WaitCompiler) emitWaitPredicateCondition(mode waitForPredicateMode, valueReg bytecode.Operand) bytecode.Operand {
switch mode {
case waitForPredicateModeValue, waitForPredicateModeExists:
+2
View File
@@ -18,6 +18,7 @@ type (
waitPredicateCompileConfig struct {
predExpr fql.IExpressionContext
whenExpr fql.IExpressionContext
jitterLiteral *float64
mode waitForPredicateMode
timeoutReg bytecode.Operand
@@ -115,6 +116,7 @@ func (c *WaitCompiler) buildWaitPredicateConfig(
return waitPredicateCompileConfig{
mode: resolveWaitPredicateMode(predicate.Value() != nil, predicate.Exists() != nil, predicate.Not() != nil),
predExpr: predExpr,
whenExpr: waitPredicateWhenExpression(ctx.WaitForPredicateWhenClause()),
timeoutReg: timeoutReg,
everyReg: everyReg,
capEveryReg: capEveryReg,
+13
View File
@@ -32,6 +32,19 @@ func (f *clauseFormatter) formatEventFilterClause(ctx *fql.EventFilterClauseCont
}
}
func (f *clauseFormatter) formatWaitForPredicateWhenClause(ctx *fql.WaitForPredicateWhenClauseContext) {
if ctx == nil {
return
}
f.writeKeyword(keywordWhen)
f.p.space()
if expr := ctx.Expression(); expr != nil {
f.expression.formatExpression(expr.(*fql.ExpressionContext))
}
}
func (f *clauseFormatter) formatLimitClause(ctx *fql.LimitClauseContext) {
if ctx == nil {
return
+14
View File
@@ -35,3 +35,17 @@ func TestClauseFormatter_EventFilterClauseUsesWhen(t *testing.T) {
t.Fatalf("unexpected event filter formatting: %q", got)
}
}
func TestClauseFormatter_WaitForPredicateWhenClause(t *testing.T) {
input := "WAITFOR VALUE ready WHEN .state == \"ready\""
program := parseProgram(t, input)
when := mustFirst[*fql.WaitForPredicateWhenClauseContext](t, program)
var buf bytes.Buffer
e := newEngine(source.NewAnonymous(input), &buf, DefaultOptions())
e.clause.formatWaitForPredicateWhenClause(when)
if got := buf.String(); got != "WHEN .state == \"ready\"" {
t.Fatalf("unexpected waitfor predicate WHEN formatting: %q", got)
}
}
+5
View File
@@ -572,6 +572,11 @@ func (f *statementFormatter) formatWaitForPredicateExpression(ctx *fql.WaitForPr
f.formatWaitForPredicate(pred.(*fql.WaitForPredicateContext))
}
if when := ctx.WaitForPredicateWhenClause(); when != nil {
f.p.space()
f.clause.formatWaitForPredicateWhenClause(when.(*fql.WaitForPredicateWhenClauseContext))
}
if timeout := ctx.TimeoutClause(); timeout != nil {
f.p.space()
f.clause.formatTimeoutClause(timeout.(*fql.TimeoutClauseContext))
+14
View File
@@ -78,6 +78,20 @@ func TestStatementFormatter_WaitForExpressionRecoveryTailCanonicalOrder(t *testi
}
}
func TestStatementFormatter_WaitForExpressionPredicateWhenClause(t *testing.T) {
input := `WAITFOR EXISTS rows WHEN LENGTH(.) >= 10 TIMEOUT 1 EVERY 10`
program := parseProgram(t, input+"\nRETURN 1")
waitExpr := mustFirst[*fql.WaitForExpressionContext](t, program)
var buf bytes.Buffer
e := newEngine(source.NewAnonymous(input), &buf, DefaultOptions())
e.statement.formatWaitForExpression(waitExpr)
if got := buf.String(); got != `WAITFOR EXISTS rows WHEN LENGTH(.) >= 10 TIMEOUT 1 EVERY 10` {
t.Fatalf("unexpected waitfor predicate WHEN formatting: %q", got)
}
}
func TestStatementFormatter_WaitForExpressionRetryTailCanonicalOrder(t *testing.T) {
input := `WAITFOR VALUE ready TIMEOUT 1 ON ERROR RETRY 3 DELAY 10MS OR RETURN NONE ON TIMEOUT RETURN FALSE`
program := parseProgram(t, input+"\nRETURN 1")
+5 -1
View File
@@ -405,6 +405,10 @@ eventFilterClause
: When {p.pushImplicitCurrent()} expression {p.popImplicitCurrent()}
;
waitForPredicateWhenClause
: When {p.pushImplicitCurrent()} expression {p.popImplicitCurrent()}
;
limitClause
: Limit limitClauseValue (Comma limitClauseValue)?
;
@@ -515,7 +519,7 @@ waitForEventExpression
;
waitForPredicateExpression
: waitForPredicate (timeoutClause)? (everyClause)? (backoffClause)? (jitterClause)?
: waitForPredicate (waitForPredicateWhenClause)? (timeoutClause)? (everyClause)? (backoffClause)? (jitterClause)?
;
waitForPredicate
File diff suppressed because one or more lines are too long
+2142 -1977
View File
File diff suppressed because it is too large Load Diff
@@ -206,6 +206,14 @@ func (s *BaseFqlParserListener) EnterEventFilterClause(ctx *EventFilterClauseCon
// ExitEventFilterClause is called when production eventFilterClause is exited.
func (s *BaseFqlParserListener) ExitEventFilterClause(ctx *EventFilterClauseContext) {}
// EnterWaitForPredicateWhenClause is called when production waitForPredicateWhenClause is entered.
func (s *BaseFqlParserListener) EnterWaitForPredicateWhenClause(ctx *WaitForPredicateWhenClauseContext) {
}
// ExitWaitForPredicateWhenClause is called when production waitForPredicateWhenClause is exited.
func (s *BaseFqlParserListener) ExitWaitForPredicateWhenClause(ctx *WaitForPredicateWhenClauseContext) {
}
// EnterLimitClause is called when production limitClause is entered.
func (s *BaseFqlParserListener) EnterLimitClause(ctx *LimitClauseContext) {}
+4
View File
@@ -131,6 +131,10 @@ func (v *BaseFqlParserVisitor) VisitEventFilterClause(ctx *EventFilterClauseCont
return v.VisitChildren(ctx)
}
func (v *BaseFqlParserVisitor) VisitWaitForPredicateWhenClause(ctx *WaitForPredicateWhenClauseContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseFqlParserVisitor) VisitLimitClause(ctx *LimitClauseContext) interface{} {
return v.VisitChildren(ctx)
}
+6
View File
@@ -100,6 +100,9 @@ type FqlParserListener interface {
// EnterEventFilterClause is called when entering the eventFilterClause production.
EnterEventFilterClause(c *EventFilterClauseContext)
// EnterWaitForPredicateWhenClause is called when entering the waitForPredicateWhenClause production.
EnterWaitForPredicateWhenClause(c *WaitForPredicateWhenClauseContext)
// EnterLimitClause is called when entering the limitClause production.
EnterLimitClause(c *LimitClauseContext)
@@ -583,6 +586,9 @@ type FqlParserListener interface {
// ExitEventFilterClause is called when exiting the eventFilterClause production.
ExitEventFilterClause(c *EventFilterClauseContext)
// ExitWaitForPredicateWhenClause is called when exiting the waitForPredicateWhenClause production.
ExitWaitForPredicateWhenClause(c *WaitForPredicateWhenClauseContext)
// ExitLimitClause is called when exiting the limitClause production.
ExitLimitClause(c *LimitClauseContext)
+3
View File
@@ -100,6 +100,9 @@ type FqlParserVisitor interface {
// Visit a parse tree produced by FqlParser#eventFilterClause.
VisitEventFilterClause(ctx *EventFilterClauseContext) interface{}
// Visit a parse tree produced by FqlParser#waitForPredicateWhenClause.
VisitWaitForPredicateWhenClause(ctx *WaitForPredicateWhenClauseContext) interface{}
// Visit a parse tree produced by FqlParser#limitClause.
VisitLimitClause(ctx *LimitClauseContext) interface{}
@@ -3,6 +3,7 @@ package compiler_test
import (
"testing"
"github.com/MontFerret/ferret/v2/pkg/bytecode"
parserd "github.com/MontFerret/ferret/v2/pkg/parser/diagnostics"
"github.com/MontFerret/ferret/v2/test/spec"
. "github.com/MontFerret/ferret/v2/test/spec/compile"
@@ -64,3 +65,31 @@ func TestWaitforCompilationErrors(t *testing.T) {
}, "Out-of-range WAITFOR EVERY float constant should fail compilation"),
})
}
func TestWaitforPredicateWhenCompiles(t *testing.T) {
RunSpecs(t, []spec.Spec{
ProgramCheck(`
RETURN WAITFOR VALUE { state: "ready" }
WHEN .state == "ready"
TIMEOUT 5ms
EVERY 1ms
ON TIMEOUT RETURN NONE
`, noCompilerError, "WAITFOR VALUE should compile with WHEN and wait tails"),
ProgramCheck(`
RETURN WAITFOR EXISTS [1, 2, 3]
WHEN LENGTH(.) >= 3
TIMEOUT 5ms
EVERY 1ms
`, noCompilerError, "WAITFOR EXISTS should compile with WHEN and wait tails"),
ProgramCheck(`
RETURN WAITFOR NOT EXISTS []
WHEN LENGTH(.) == 0
TIMEOUT 5ms
EVERY 1ms
`, noCompilerError, "WAITFOR NOT EXISTS should compile with WHEN and wait tails"),
})
}
func noCompilerError(*bytecode.Program) error {
return nil
}
@@ -1,8 +1,13 @@
package vm_test
import (
"context"
"fmt"
"testing"
"github.com/MontFerret/ferret/v2/pkg/compiler"
"github.com/MontFerret/ferret/v2/pkg/runtime"
"github.com/MontFerret/ferret/v2/pkg/vm"
"github.com/MontFerret/ferret/v2/test/spec"
. "github.com/MontFerret/ferret/v2/test/spec/exec"
)
@@ -89,5 +94,97 @@ func TestWaitforPredicate(t *testing.T) {
LET token = WAITFOR VALUE NONE TIMEOUT 20ms EVERY 5ms ON ERROR FAIL
RETURN token
`, "Explicit FAIL should preserve timeout result semantics"),
Object(`
LET value = WAITFOR VALUE { ok: true, kind: "candidate" } WHEN .ok
RETURN value
`, map[string]any{"ok": true, "kind": "candidate"}, "WAITFOR VALUE WHEN should return the candidate value"),
S(`
LET ok = WAITFOR EXISTS [1, 2, 3] WHEN LENGTH(.) >= 3
RETURN ok
`, true, "WAITFOR EXISTS WHEN should bind the full candidate array"),
S(`
LET ok = WAITFOR NOT EXISTS [] WHEN LENGTH(.) == 0
RETURN ok
`, true, "WAITFOR NOT EXISTS WHEN should bind the not-existing candidate value"),
Nil(`
LET token = WAITFOR VALUE "ok" WHEN false TIMEOUT 20ms EVERY 5ms ON TIMEOUT RETURN NONE
RETURN token
`, "WAITFOR VALUE WHEN should honor ON TIMEOUT when the predicate never passes"),
S(`
LET ok = WAITFOR EXISTS [1] WHEN false TIMEOUT 20ms EVERY 5ms ON TIMEOUT RETURN false
RETURN ok
`, false, "WAITFOR EXISTS WHEN should honor ON TIMEOUT when the predicate never passes"),
})
}
func TestWaitforPredicateWhenRetriesUntilTrue(t *testing.T) {
for _, level := range []compiler.OptimizationLevel{compiler.O0, compiler.O1} {
callCount := 0
RunSpecsWith(
t,
fmt.Sprintf("VM/O%d", level),
compiler.New(compiler.WithOptimizationLevel(level)),
[]spec.Spec{
S(`
LET token = WAITFOR VALUE CANDIDATE() WHEN .state == "ready" TIMEOUT 100ms EVERY 0
RETURN token.value
`, "ok", "WAITFOR VALUE WHEN should retry until the predicate passes"),
},
vm.WithFunction("CANDIDATE", func(ctx context.Context, args ...runtime.Value) (runtime.Value, error) {
callCount++
state := "pending"
if callCount >= 3 {
state = "ready"
}
return runtime.NewObjectWith(map[string]runtime.Value{
"state": runtime.NewString(state),
"value": runtime.NewString("ok"),
}), nil
}),
)
if got, want := callCount, 3; got != want {
t.Fatalf("unexpected WAITFOR VALUE WHEN candidate call count for O%d: got %d, want %d", level, got, want)
}
}
}
func TestWaitforPredicateWhenSkipsPredicateUntilBasePasses(t *testing.T) {
for _, level := range []compiler.OptimizationLevel{compiler.O0, compiler.O1} {
predicateCalls := 0
RunSpecsWith(
t,
fmt.Sprintf("VM/O%d", level),
compiler.New(compiler.WithOptimizationLevel(level)),
[]spec.Spec{
S(`
LET ok = WAITFOR EXISTS NONE WHEN PREDICATE(.) TIMEOUT 20ms EVERY 1ms ON TIMEOUT RETURN false
RETURN ok
`, false, "WAITFOR EXISTS WHEN should not evaluate the predicate before existence passes"),
},
vm.WithFunction("PREDICATE", func(ctx context.Context, args ...runtime.Value) (runtime.Value, error) {
predicateCalls++
return runtime.True, nil
}),
)
if got := predicateCalls; got != 0 {
t.Fatalf("WAITFOR EXISTS WHEN evaluated predicate before existence passed for O%d: got %d calls", level, got)
}
}
}
func TestWaitforPredicateWhenUsesOperationErrorPolicy(t *testing.T) {
RunSpecs(t, []spec.Spec{
S(`
RETURN WAITFOR VALUE "ok" WHEN FAIL_PREDICATE(.) TIMEOUT 20ms EVERY 0 ON ERROR RETURN "error"
`, "error", "WAITFOR VALUE WHEN predicate errors should use the wait error policy"),
}, vm.WithFunction("FAIL_PREDICATE", func(ctx context.Context, args ...runtime.Value) (runtime.Value, error) {
return runtime.None, fmt.Errorf("predicate failed")
}))
}