Skip to content

Commit 24e6dab

Browse files
committed
Add internal query linter
When working on the compiler it's easy to accidentally break something that results in a segfault. Most of these errors can be detected by checks on the Codegen IR, in particular correct type-safety and variable-scoping. - Add Glean.Query.Lint to implement query consistency checks - Turn on query linting with `GLEAN_DEBUG=lint` or `--debug-query-lint` - Linting is automatically enabled for all tests via `withTestEnv`
1 parent 0c7da38 commit 24e6dab

8 files changed

Lines changed: 314 additions & 3 deletions

File tree

glean.cabal.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ library db
673673
Glean.Query.Expand
674674
Glean.Query.Flatten
675675
Glean.Query.Flatten.Types
676+
Glean.Query.Lint
676677
Glean.Query.Opt
677678
Glean.Query.Prune
678679
Glean.Query.Reorder

glean/db/Glean/Database/Config.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,17 @@ data Config = Config
212212
data DebugFlags = DebugFlags
213213
{ tcDebug :: !Bool
214214
, queryDebug :: !Bool
215+
, queryLint :: !Bool
215216
}
216217

217218
instance Default DebugFlags where
218-
def = DebugFlags { tcDebug = False, queryDebug = False }
219+
def = DebugFlags { tcDebug = False, queryDebug = False, queryLint = False }
219220

220221
instance Semigroup DebugFlags where
221222
a <> b = DebugFlags
222223
{ tcDebug = tcDebug a || tcDebug b
223224
, queryDebug = queryDebug a || queryDebug b
225+
, queryLint = queryLint a || queryLint b
224226
}
225227

226228
instance Monoid DebugFlags where
@@ -571,6 +573,7 @@ options = do
571573
debugParser = do
572574
tcDebug <- switch (long "debug-tc")
573575
queryDebug <- switch (long "debug-query")
576+
queryLint <- switch (long "debug-query-lint")
574577
return DebugFlags{..}
575578

576579
serverConfigThriftSource = option (eitherReader ThriftSource.parse)

glean/db/Glean/Database/Env.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ getDebugEnv = do
164164
where
165165
add "tc" = return def { tcDebug = True }
166166
add "query" = return def { queryDebug = True }
167+
add "lint" = return def { queryLint = True }
167168
add other = do
168169
logWarning $ "Unkonwn GLEAN_DEBUG class: " <> other
169170
return def

glean/db/Glean/Query/Lint.hs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
{-
2+
Copyright (c) Meta Platforms, Inc. and affiliates.
3+
All rights reserved.
4+
5+
This source code is licensed under the BSD-style license found in the
6+
LICENSE file in the root directory of this source tree.
7+
-}
8+
9+
module Glean.Query.Lint (
10+
lintQuery
11+
) where
12+
13+
import Control.Monad
14+
import Control.Monad.Except
15+
import Control.Monad.State
16+
import Data.List (foldl')
17+
import Data.IntSet (IntSet)
18+
import qualified Data.IntSet as IntSet
19+
import Compat.Prettyprinter
20+
21+
import Glean.Angle.Types
22+
(Type_(..), latestAngleVersion, tempPredicateId)
23+
import Glean.Database.Schema.Types
24+
(DbSchema, PredicateDetails(..), lookupPid, lookupPredicateId)
25+
import Glean.Display
26+
import Glean.Query.Codegen.Types
27+
import Glean.RTS.Term as RTS
28+
import Glean.RTS.Types as RTS
29+
import Glean.Schema.Util (tupleSchema)
30+
31+
-- | The lint monad: tracks the set of bound variable IDs in state,
32+
-- and can throw a pretty-printed error.
33+
type L a = StateT IntSet (Except (Doc ())) a
34+
35+
runL :: L a -> Either (Doc ()) a
36+
runL m = runExcept (evalStateT m IntSet.empty)
37+
38+
lintError :: Doc () -> L a
39+
lintError = throwError
40+
41+
-- | Record a variable as bound.
42+
bindVar :: Var -> L ()
43+
bindVar v = modify (IntSet.insert (varId v))
44+
45+
-- | Check that a variable is currently bound.
46+
requireBound :: Var -> L ()
47+
requireBound v = do
48+
bound <- get
49+
unless (varId v `IntSet.member` bound) $
50+
lintError $ "unbound variable:" <+> displayDefault v
51+
52+
-- | Run an action in a nested scope. The bound set is saved and
53+
-- restored afterwards, so bindings made inside do not escape.
54+
scoped :: L a -> L a
55+
scoped m = do
56+
saved <- get
57+
a <- m
58+
put saved
59+
return a
60+
61+
{-
62+
Check the query for internal consistency. lintQuery will check
63+
the following properties of the query:
64+
65+
- type-correctness, e.g.
66+
- in A = B, the types of A and B are equivalent
67+
- in FactGenerator and DerivedFactGenerator, the types of the key
68+
and value pattern match those of the predicate in the schema
69+
- arguments of primitives have the correct type
70+
- bind-before-use: for all variables, MatchBind occurs before MatchVar
71+
72+
-}
73+
lintQuery :: DbSchema -> CodegenQuery -> Either (Doc ()) ()
74+
lintQuery schema QueryWithInfo{..} = runL $ do
75+
let CgQuery head body = qiQuery
76+
lintStatements schema body
77+
lintPat head
78+
case qiGenerator of
79+
Nothing -> checkEqType "query head" head qiReturnType
80+
Just gen -> do
81+
lintGenerator gen
82+
lintGeneratorTypes schema head gen
83+
84+
-- ---------------------------------------------------------------------------
85+
-- Bind-before-use and type checking for statement lists
86+
87+
lintStatements :: DbSchema -> [CgStatement] -> L ()
88+
lintStatements schema = mapM_ (lintStatement schema)
89+
90+
lintStatement :: DbSchema -> CgStatement -> L ()
91+
lintStatement schema = \case
92+
CgStatement pat gen -> do
93+
-- The generator is evaluated first; its patterns can bind variables
94+
lintGenerator gen
95+
-- Then the LHS pattern matches the generator result, and can use
96+
-- variables bound by the generator as well as previously bound ones
97+
lintPat pat
98+
-- Type check: pattern type should match generator result type
99+
lintStmtTypes schema pat gen
100+
101+
CgAllStatement var expr stmts -> do
102+
-- The inner statements are evaluated in a nested scope
103+
scoped $ do
104+
lintStatements schema stmts
105+
lintPat expr
106+
-- The variable bound by 'all' is available to subsequent statements
107+
bindVar var
108+
109+
CgNegation stmts ->
110+
-- Negation introduces its own scope; bindings do not escape
111+
scoped $ lintStatements schema stmts
112+
113+
CgDisjunction stmtss -> do
114+
-- Each branch starts with the same bound set. Variables bound
115+
-- in all branches are available afterwards.
116+
bounds <- forM stmtss $ \stmts -> scoped $ do
117+
lintStatements schema stmts
118+
get
119+
case bounds of
120+
[] -> return ()
121+
(b:bs) -> put (foldl' IntSet.intersection b bs)
122+
123+
CgConditional{..} -> do
124+
outer <- get
125+
lintStatements schema cond
126+
-- then branch sees cond bindings
127+
lintStatements schema then_
128+
thenBound <- get
129+
-- else branch only sees pre-condition bindings
130+
put outer
131+
lintStatements schema else_
132+
elseBound <- get
133+
-- Variables bound in both branches are available after
134+
put (IntSet.intersection thenBound elseBound)
135+
136+
-- ---------------------------------------------------------------------------
137+
-- Check uses and record bindings in a single depth-first left-to-right
138+
-- traversal, so that a MatchBind makes the variable immediately
139+
-- available for a later MatchVar in the same pattern.
140+
141+
lintPat :: Pat -> L ()
142+
lintPat = mapM_ lintMatch
143+
144+
lintMatch :: Match () Var -> L ()
145+
lintMatch = \case
146+
MatchBind v -> bindVar v
147+
MatchVar v -> requireBound v
148+
MatchAnd a b -> do
149+
lintPat a
150+
lintPat b
151+
MatchPrefix _ rest ->
152+
lintPat rest
153+
MatchArrayPrefix _ pre whole -> do
154+
mapM_ lintPat pre
155+
lintPat whole
156+
_ -> return ()
157+
158+
-- | Lint expression-position subterms of a generator, then bind
159+
-- pattern-position variables.
160+
lintGenerator :: Generator -> L ()
161+
lintGenerator = \case
162+
FactGenerator _ kpat vpat _ -> do
163+
lintPat kpat
164+
lintPat vpat
165+
TermGenerator expr ->
166+
lintPat expr
167+
DerivedFactGenerator _ key value -> do
168+
lintPat key
169+
lintPat value
170+
ArrayElementGenerator _ arr ->
171+
lintPat arr
172+
SetElementGenerator _ set ->
173+
lintPat set
174+
PrimCall _ args _ ->
175+
mapM_ lintPat args
176+
177+
-- | Type-check the result generator (qiGenerator). The head expression
178+
-- is the fact ID that the generator will look up.
179+
lintGeneratorTypes :: DbSchema -> Pat -> Generator -> L ()
180+
lintGeneratorTypes schema head gen = case gen of
181+
FactGenerator pidRef kpat vpat _ -> do
182+
mdetails <- lookupPredicate schema pidRef
183+
forM_ mdetails $ \details -> do
184+
checkEqType
185+
("result generator key of" <+> displayDefault pidRef)
186+
kpat (predicateKeyType details)
187+
checkEqType
188+
("result generator value of" <+> displayDefault pidRef)
189+
vpat (predicateValueType details)
190+
checkEqType "result generator fact ID" head (PredicateTy () pidRef)
191+
_ -> return ()
192+
193+
-- ---------------------------------------------------------------------------
194+
-- Type checking
195+
196+
-- | Check the type consistency of a statement: pattern type vs
197+
-- generator result type, and predicate key/value types for fact
198+
-- generators.
199+
lintStmtTypes :: DbSchema -> Pat -> Generator -> L ()
200+
lintStmtTypes schema pat gen = case gen of
201+
FactGenerator pidRef kpat vpat _ -> do
202+
mdetails <- lookupPredicate schema pidRef
203+
forM_ mdetails $ \details -> do
204+
checkEqType
205+
("key of" <+> displayDefault pidRef) kpat (predicateKeyType details)
206+
checkEqType
207+
("value of" <+> displayDefault pidRef) vpat (predicateValueType details)
208+
-- The LHS pattern matches the fact ID, which has the predicate type
209+
checkEqType "fact generator result" pat (PredicateTy () pidRef)
210+
211+
DerivedFactGenerator pidRef key value -> do
212+
mdetails <- lookupPredicate schema pidRef
213+
forM_ mdetails $ \details -> do
214+
checkEqType
215+
("key of" <+> displayDefault pidRef) key (predicateKeyType details)
216+
checkEqType
217+
("value of" <+> displayDefault pidRef) value (predicateValueType details)
218+
checkEqType "derived fact generator result" pat (PredicateTy () pidRef)
219+
220+
TermGenerator expr ->
221+
case (patType pat, patType expr) of
222+
(Just pty, Just ety) -> typesShouldMatch "term generator" pty ety
223+
_ -> return ()
224+
225+
ArrayElementGenerator eltTy _arr ->
226+
checkEqType "array element generator" pat eltTy
227+
228+
SetElementGenerator eltTy _set ->
229+
checkEqType "set element generator" pat eltTy
230+
231+
PrimCall _op _args retTy ->
232+
checkEqType "primcall result" pat retTy
233+
234+
lookupPredicate :: DbSchema -> PidRef -> L (Maybe PredicateDetails)
235+
lookupPredicate schema (PidRef pid predId)
236+
-- The temporary predicate used for derived fact generators is not
237+
-- stored in the schema, so skip it.
238+
| predId == tempPredicateId = return Nothing
239+
| otherwise =
240+
case lookupPid pid schema of
241+
Nothing -> case lookupPredicateId predId schema of
242+
Nothing ->
243+
lintError $ "unknown predicate:" <+> displayDefault predId
244+
Just details -> return (Just details)
245+
Just details -> return (Just details)
246+
247+
-- | Check that the type of a pattern matches an expected type.
248+
-- When the pattern's type cannot be determined, the check is skipped.
249+
checkEqType :: Doc () -> Pat -> Type -> L ()
250+
checkEqType ctx pat expected =
251+
case patType pat of
252+
Nothing -> return ()
253+
Just actual -> typesShouldMatch ctx actual expected
254+
255+
typesShouldMatch :: Doc () -> Type -> Type -> L ()
256+
typesShouldMatch ctx actual expected
257+
| eqType latestAngleVersion actual expected = return ()
258+
| otherwise = throwError $ vcat
259+
[ "type mismatch in" <+> ctx <> ":"
260+
, " expected:" <+> displayDefault expected
261+
, " actual:" <+> displayDefault actual
262+
]
263+
264+
-- ---------------------------------------------------------------------------
265+
-- Inferring the type of a pattern
266+
267+
-- | Attempt to determine the type of a pattern. Returns Nothing when
268+
-- the type cannot be determined from the pattern structure alone
269+
-- (e.g. an empty array or an Alt without surrounding context).
270+
patType :: Pat -> Maybe Type
271+
patType (Byte _) = Just ByteTy
272+
patType (Nat _) = Just NatTy
273+
patType (RTS.String _) = Just StringTy
274+
patType (ByteArray _) = Just (ArrayTy ByteTy)
275+
patType (Array []) = Nothing
276+
patType (Array (t:_)) = ArrayTy <$> patType t
277+
patType (Tuple ts) = tupleSchema <$> traverse patType ts
278+
patType (Alt _ _) = Nothing
279+
patType (Ref m) = matchType m
280+
281+
matchType :: Match () Var -> Maybe Type
282+
matchType (MatchWild ty) = Just ty
283+
matchType (MatchNever ty) = Just ty
284+
matchType (MatchBind v) = Just (varType v)
285+
matchType (MatchVar v) = Just (varType v)
286+
matchType (MatchFid _) = Nothing
287+
matchType (MatchAnd a _) = patType a
288+
matchType (MatchPrefix _ _) = Just StringTy
289+
matchType (MatchArrayPrefix ty _ _) = Just (ArrayTy ty)
290+
matchType (MatchExt _) = Nothing

glean/db/Glean/Query/UserQuery.hs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import Glean.Query.Flatten
7575
import Glean.Query.Opt
7676
import Glean.Query.Reorder
7777
import Glean.Query.Incremental (makeIncremental)
78+
import Glean.Query.Lint (lintQuery)
7879
import Glean.RTS as RTS
7980
import Glean.RTS.Bytecode.Disassemble
8081
import qualified Glean.RTS.Bytecode.Gen.Version as Bytecode
@@ -998,6 +999,12 @@ compileAngleQuery rec ver dbSchema mode source stored debug = do
998999
reordered <- checkBadQuery id $ runExcept $ reorder dbSchema optimised
9991000
ifDebug $ "reordered query: " <> show (displayDefault (qiQuery reordered))
10001001

1002+
when (queryLint debug) $
1003+
case lintQuery dbSchema reordered of
1004+
Left err -> throwIO $ Thrift.BadQuery $
1005+
Text.pack $ show $ "query lint:" <+> err
1006+
Right () -> return ()
1007+
10011008
final <- case mode of
10021009
NoExtraSteps -> return reordered
10031010
IncrementalDerivation getStats -> do

glean/hs/Glean/RTS/Types.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ eqType version a b = case (a,b) of
143143
(NatTy, NatTy) -> True
144144
(StringTy, StringTy) -> True
145145
(ArrayTy a, ArrayTy b) -> eqType version a b
146+
(SetTy a, SetTy b) -> eqType version a b
146147
(RecordTy as, RecordTy bs) ->
147148
let isTuple = all (Text.isInfixOf "tuplefield" . fieldDefName)
148149
-- previous to version 7 records were always compared structurally

glean/test/lib/Glean/Database/Test.hs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module Glean.Database.Test
2424
, setUseCheckpoint
2525
, enableTcDebug
2626
, enableQueryDebug
27+
, enableQueryLint
2728
, enableRocksDBCache
2829
, withTestEnv
2930
, kickOffTestDB
@@ -135,6 +136,10 @@ enableQueryDebug :: Setting
135136
enableQueryDebug cfg = cfg
136137
{ cfgDebug = (cfgDebug cfg) { queryDebug = True } }
137138

139+
enableQueryLint :: Setting
140+
enableQueryLint cfg = cfg
141+
{ cfgDebug = (cfgDebug cfg) { queryLint = True } }
142+
138143
enableRocksDBCache :: Setting
139144
enableRocksDBCache cfg = cfg
140145
{ cfgServerConfig = (cfgServerConfig cfg) <&> \scfg -> scfg
@@ -152,12 +157,12 @@ withTestEnv settings action =
152157
\(cfgAPI :: NullConfigProvider) -> do
153158
let
154159
dbConfig = foldl' (\acc f -> f acc)
155-
def
160+
(enableQueryLint def
156161
{ cfgDataStore = tmpDataStore
157162
, cfgSchemaLocation = Just schemaLocationFiles
158163
, cfgServerConfig = ThriftSource.value def
159164
{ ServerConfig.config_db_rocksdb_cache_mb = 0 }
160-
}
165+
})
161166
settings
162167

163168
withDatabases evb dbConfig cfgAPI action

0 commit comments

Comments
 (0)