Repository: edgedb/edgedb

Issue:
Manual error status for EdgeDB server
# Motivation

`edgedb watch` command is expected to be running in the background and update schema in the database. Sometimes schema updates cannot be applied because of syntax of schema files is wrong or because database changes are conflicting. In those cases we have to notify users somehow that schema changes could not be applied.

It's hard to make notifications from the background process, so the simplest solution is to error out all clients. So all subsequent testing that user does fails with the same error until schema files are fixed.

# Overview

We use configuration mechanism to propagate error to all the clients that use the same database.

Example on conflicting type:
```
CONFIGURE DATABASE SET database_failure := "edgedb watch: SchemaError: property 'value' of object type 'default::Bar' cannot be cast automatically from scalar type 'std::int32' to scalar type 'std::str. Please fix syntax error before proceeding."
```
Example syntax error:
```
CONFIGURE DATABASE SET database_error := "edgedb watch: Error reading schema files. Please fix syntax error before proceeding."
CONFIGURE DATABASE SET database_error_info := '{"file": "dbschema/default.esdl", "line": 10, "column": 16, "message": "Syntax error: unexpected `)`"}'
```
Example when database upgrade needed:
```
CONFIGURE DATABASE SET database_warning := "edgedb watch: current EdgeDB version is 2.3+deadbee while minimum of 2.7 required by edgedb.toml"
```

# Specification

New error type must be introduced. `DevModeError` (better name?), probably a subclass of `BackendError` (or `ConfigurationError`?)

Config parameters:

* `database_error` -- this error is thrown in every client when any non-configuration query is performed (i.e. any CONFIGURE is allowed as well as selecting from `cfg::` and `sys::get_version*`, maybe all stdlib is allowed?)
* `database_error_info` -- JSON with extra information (format to be determined). This can be used in UI-based tools to provide nicer error. Has to be fetched manually via `SELECT cfg::Config`.
* `database_warning` -- sent as a warning to all the clients (when we start supporting warning mechanism)

This mechanism is error by default, but generally advisory. To opt-out of error you can do:
```
CONFIGURE SESSION SET database_error := {};
```
This mechanism will be used by CLI for two things:
1. For `edgedb watch` / `edgedb migrate` to fix the schema after resetting changes
2. `edgedb` (REPL) will print the failure and clean `database_error` for the session, so you can inspect things while error is there.


Discussion:
The mechanism looks reasonable to me, however I wonder if we can (or should) generalize this to allow specifying the error class and whether or not the state is advisory.  One other potential use case I'm contemplating is Cloud upgrades, where the source instance will be placed into read-only mode.
> The mechanism looks reasonable to me, however I wonder if we can (or should) generalize this to allow specifying the error class and whether or not the state is advisory. One other potential use case I'm contemplating is Cloud upgrades, where the source instance will be placed into read-only mode.

Yeah. This sounds like a good idea. But I'm not sure about specifics.

Also I'm not sure how to make "non-advisory" state, until we have rule-based access controls (RBAC). Someone should have a permission to reset that state, and since all users are superusers every user can do that.

Resolving patch:
diff --git a/edb/buildmeta.py b/edb/buildmeta.py
--- a/edb/buildmeta.py
+++ b/edb/buildmeta.py
@@ -44,7 +44,7 @@
 
 
 # Increment this whenever the database layout or stdlib changes.
-EDGEDB_CATALOG_VERSION = 2022_01_31_00_00
+EDGEDB_CATALOG_VERSION = 2022_02_01_00_00
 EDGEDB_MAJOR_VERSION = 3
 
 
diff --git a/edb/errors/base.py b/edb/errors/base.py
--- a/edb/errors/base.py
+++ b/edb/errors/base.py
@@ -34,10 +34,14 @@
 
 class EdgeDBErrorMeta(type):
     _error_map: Dict[int, Type[EdgeDBError]] = {}
+    _name_map: Dict[str, Type[EdgeDBError]] = {}
 
     def __new__(mcls, name, bases, dct):
         cls = super().__new__(mcls, name, bases, dct)
 
+        assert name not in mcls._name_map
+        mcls._name_map[name] = cls
+
         code = dct.get('_code')
         if code is not None:
             mcls._error_map[code] = cls
@@ -53,9 +57,13 @@ def __init__(cls, name, bases, dct):
                 'subclass one of its subclasses in edb.errors')
 
     @classmethod
-    def get_error_class_from_code(mcls, code):
+    def get_error_class_from_code(mcls, code: int) -> Type[EdgeDBError]:
         return mcls._error_map[code]
 
+    @classmethod
+    def get_error_class_from_name(mcls, name: str) -> Type[EdgeDBError]:
+        return mcls._name_map[name]
+
 
 class EdgeDBMessage(Warning):
 
@@ -82,7 +90,8 @@ def __init__(
         hint: Optional[str] = None,
         details: Optional[str] = None,
         context=None,
-        position: Optional[Tuple[int, int, int]] = None,
+        position: Optional[tuple[Optional[int], ...]] = None,
+        filename: Optional[str] = None,
         token=None,
         pgext_code: Optional[str] = None,
     ):
@@ -99,6 +108,9 @@ def __init__(
         elif position:
             self.set_position(*position)
 
+        if filename is not None:
+            self.set_filename(filename)
+
         self.set_hint_and_details(hint, details)
 
         super().__init__(msg)
@@ -110,9 +122,14 @@ def get_code(cls):
                 f'EdgeDB message code is not set (type: {cls.__name__})')
         return cls._code
 
+    def set_filename(self, filename):
+        self._attrs[FIELD_FILENAME] = filename
+
     def set_linecol(self, line, col):
-        self._attrs[FIELD_LINE_START] = str(line)
-        self._attrs[FIELD_COLUMN_START] = str(col)
+        if line is not None:
+            self._attrs[FIELD_LINE_START] = str(line)
+        if col is not None:
+            self._attrs[FIELD_COLUMN_START] = str(col)
 
     def set_hint_and_details(self, hint, details=None):
         ex.replace_context(
@@ -141,11 +158,22 @@ def set_source_context(self, context):
         self._attrs[FIELD_LINE_END] = str(end.line)
         self._attrs[FIELD_COLUMN_END] = str(end.column)
         self._attrs[FIELD_UTF16_COLUMN_END] = str(end.utf16column)
+        if context.name and context.name != '<string>':
+            self._attrs[FIELD_FILENAME] = context.name
 
-    def set_position(self, line: int, column: int, pointer: int):
+    def set_position(
+        self,
+        line: Optional[int] = None,
+        column: Optional[int] = None,
+        start: Optional[int] = None,
+        end: Optional[int] = None,
+    ):
         self.set_linecol(line, column)
-        self._attrs[FIELD_POSITION_START] = str(pointer)
-        self._attrs[FIELD_POSITION_END] = str(pointer)
+        if start is not None:
+            self._attrs[FIELD_POSITION_START] = str(start)
+        end = end or start
+        if end is not None:
+            self._attrs[FIELD_POSITION_END] = str(end)
 
     @property
     def line(self):
@@ -197,3 +225,4 @@ def ensure_context(context: Any) -> Iterator[None]:
 FIELD_UTF16_COLUMN_END = 0x_FF_F8
 FIELD_CHARACTER_START = 0x_FF_F9
 FIELD_CHARACTER_END = 0x_FF_FA
+FIELD_FILENAME = 0x_FF_FB
diff --git a/edb/server/compiler/compiler.py b/edb/server/compiler/compiler.py
--- a/edb/server/compiler/compiler.py
+++ b/edb/server/compiler/compiler.py
@@ -1807,6 +1807,9 @@ def _try_compile(
     for i, stmt in enumerate(statements):
         is_trailing_stmt = i == statements_len - 1
         stmt_ctx = ctx if is_trailing_stmt else non_trailing_ctx
+
+        _check_force_database_error(stmt_ctx, stmt)
+
         comp, capabilities = _compile_dispatch_ql(
             stmt_ctx,
             stmt,
@@ -2328,6 +2331,46 @@ def _get_data_mending_desc(
         )
 
 
+def _check_force_database_error(
+    ctx: CompileContext,
+    ql: qlast.Base,
+) -> None:
+    if isinstance(ql, qlast.ConfigOp):
+        return
+
+    try:
+        val = _get_config_val(ctx, 'force_database_error')
+        # Check the string directly for false to skip a deserialization
+        if val is None or val == 'false':
+            return
+        err = json.loads(val)
+        if not err:
+            return
+
+        errcls = errors.EdgeDBError.get_error_class_from_name(err['type'])
+        if context := err.get('context'):
+            filename = context.get('filename')
+            position = tuple(
+                context.get(k) for k in ('line', 'col', 'start', 'end')
+            )
+        else:
+            filename = None
+            position = None
+
+        errval = errcls(
+            msg=err.get('message'),
+            hint=err.get('hint'),
+            details=err.get('details'),
+            filename=filename,
+            position=position,
+        )
+    except Exception:
+        raise errors.ConfigurationError(
+            "invalid 'force_database_error' value'")
+
+    raise errval
+
+
 def _is_dev_instance(ctx: CompileContext) -> bool:
     # Determine whether we are on a dev instance by the presence
     # of a test schema element.


Test patch:
diff --git a/tests/test_server_config.py b/tests/test_server_config.py
--- a/tests/test_server_config.py
+++ b/tests/test_server_config.py
@@ -35,6 +35,7 @@
 from edb import buildmeta
 from edb import errors
 from edb.edgeql import qltypes
+from edb.edgeql import quote as qlquote
 
 from edb.testbase import server as tb
 from edb.schema import objects as s_obj
@@ -1174,6 +1175,50 @@ async def test_server_proto_rollback_state(self):
         finally:
             await con2.aclose()
 
+    async def test_server_proto_configure_error(self):
+        con1 = self.con
+        con2 = await self.connect(database=con1.dbname)
+        try:
+            await con2.execute('''
+                select 1;
+            ''')
+
+            err = {
+                'type': 'SchemaError',
+                'message': 'danger',
+                'context': {'start': 42},
+            }
+            await con1.execute(f'''
+                configure current database set force_database_error :=
+                  {qlquote.quote_literal(json.dumps(err))};
+            ''')
+
+            with self.assertRaisesRegex(edgedb.SchemaError, 'danger'):
+                async for tx in con1.retrying_transaction():
+                    async with tx:
+                        await tx.query('select schema::Object')
+
+            # It might take a bit before con2 sees the error config
+            async for tr in self.try_until_succeeds(
+                    ignore=AssertionError):
+                async with tr:
+                    with self.assertRaisesRegex(edgedb.SchemaError, 'danger',
+                                                _position=42):
+                        async for tx in con2.retrying_transaction():
+                            async with tx:
+                                await tx.query('select schema::Object')
+
+            await con2.execute(f'''
+                configure session set force_database_error := "false";
+            ''')
+            await con2.query('select schema::Object')
+
+        finally:
+            await con1.execute(f'''
+                configure current database reset force_database_error;
+            ''')
+            await con2.aclose()
+
 
 class TestSeparateCluster(tb.TestCase):