On reconnect the Python IDL discards its local replica with __clear() and repopulates it from a fresh monitor dump. __clear() dropped the old rows silently, so clients saw only a ROW_CREATE for every row in the new snapshot -- a create-for-every-row "storm" that told the client nothing about what actually changed while it was disconnected (rows that were unchanged or modified looked identical: a fresh create, deletes missing).
Record a ROW_DELETE for each row __clear() discards, collect the notices produced while parsing a monitor reply (keyed by row UUID) instead of calling notify() inline, and on reconnect deliver them through a new notify_reconnect() hook. The notices are handed over as a ReconciledNotices view that pairs the __clear() DELETE with the re-dump CREATE per UUID and reconciles them: - unchanged row: suppressed - modified row: single ROW_UPDATE with only changed columns - row deleted while gone: ROW_DELETE - row created while gone: ROW_CREATE The initial database download and all incremental updates still flow through notify() unchanged. The default notify_reconnect() forwards each reconciled notice to notify(), so a subclass that only overrides notify() still learns about post-reconnect changes (now reconciled rather than as a storm); overriding notify_reconnect() receives the whole set in one call and suppresses the per-row notify() calls for the reconnect dump. First connect is distinguished from reconnect via has_ever_connected(). To keep that signal honest, only bump change_seqno on a lock reply once the IDL has reached the MONITORING state, so lock acquisition during the initial handshake no longer makes a first connect look like a reconnect. Adds pytest coverage for ReconciledNotices and functional coverage for both the default (notify()) and overridden notify_reconnect() paths. Assisted-by: Claude Opus 4.8 <[email protected]> Signed-off-by: Terry Wilson <[email protected]> --- v3: Note: Supersedes python: Add notify_on_clear for reconciled notices. - Removes opt-in for the main patch - Adds notify_reconnect() instead of batch_notify() python/automake.mk | 3 +- python/ovs/db/idl.py | 179 +++++++++++++- python/ovs/tests/test_reconciled_notices.py | 255 ++++++++++++++++++++ tests/ovsdb-idl.at | 23 +- tests/test-ovsdb.py | 24 ++ 5 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 python/ovs/tests/test_reconciled_notices.py diff --git a/python/automake.mk b/python/automake.mk index c3e960c82..affee9ca7 100644 --- a/python/automake.mk +++ b/python/automake.mk @@ -49,7 +49,8 @@ ovs_pytests = \ python/ovs/tests/test_kv.py \ python/ovs/tests/test_list.py \ python/ovs/tests/test_odp.py \ - python/ovs/tests/test_ofp.py + python/ovs/tests/test_ofp.py \ + python/ovs/tests/test_reconciled_notices.py ovs_flowviz = \ python/ovs/flowviz/__init__.py \ diff --git a/python/ovs/db/idl.py b/python/ovs/db/idl.py index fe504a63a..4d4b83332 100644 --- a/python/ovs/db/idl.py +++ b/python/ovs/db/idl.py @@ -47,6 +47,66 @@ Notice = collections.namedtuple('Notice', ('event', 'row', 'updates')) Notice.__new__.__defaults__ = (None,) # default updates=None +class ReconciledNotices: + """Lazy view over pending notices, reconciling reconnect events. + + On reconnect the IDL discards its local replica with __clear() and + repopulates it from a fresh monitor dump. When notify_reconnect() + is in use, __clear() records a ROW_DELETE notice for every + pre-existing row and the subsequent dump records a ROW_CREATE notice + for every row in the new snapshot. This view reconciles the + DELETE+CREATE pair collected per UUID: + + - Same data: suppressed (the row survived the reconnect unchanged) + - Different data: emitted as ROW_UPDATE with only the changed columns + - DELETE only: emitted (row was deleted while disconnected) + - CREATE only: emitted (row appeared while disconnected) + + Iteration yields the reconciled Notice objects. __getitem__(uuid) + returns the reconciled Notice for a specific UUID, or None if that + UUID reconciles away. Reconciliation is computed on access; nothing + is cached. + """ + + def __init__(self, notices): + self._raw = notices + + def __getitem__(self, row_uuid): + return self._reconcile(row_uuid, self._raw[row_uuid]) + + def __iter__(self): + for row_uuid in self._raw: + notice = self._reconcile(row_uuid, self._raw[row_uuid]) + if notice is not None: + yield notice + + @staticmethod + def _reconcile(row_uuid, events): + if len(events) == 1: + return events[0] + if ( + len(events) == 2 and + events[0].event == ROW_DELETE and + events[1].event == ROW_CREATE + ): + old_row = events[0].row + new_row = events[1].row + old_data = {} + for col in old_row._table.columns: + if col in old_row._data and col in new_row._data: + if old_row._data[col] != new_row._data[col]: + old_data[col] = old_row._data[col] + if old_data: + # Carry the real idl reference (from the new row) on the + # "updates" row so reads of reference-typed columns can + # resolve, matching notify()'s ROW_UPDATE semantics. + return Notice(ROW_UPDATE, new_row, + Row(new_row._idl, old_row._table, row_uuid, + old_data)) + return None + assert False, "unexpected number of events for row %s" % row_uuid + + class ColumnDefaultDict(dict): """A column dictionary with on-demand generated default values @@ -315,6 +375,12 @@ class Idl(object): self.cond_changed = False self.cond_seqno = 0 + # Notices produced while parsing a single monitor reply (and the + # DELETE notices produced by __clear() on reconnect) are collected + # here keyed by row UUID, then flushed once parsing is complete. + # See _flush_notices(). + self._pending_notices = collections.defaultdict(list) + def _parse_remotes(self, remote): # If remote is - # "tcp:10.0.0.1:6641,unix:/tmp/db.sock,t,s,tcp:10.0.0.2:6642" @@ -487,6 +553,11 @@ class Idl(object): and self._monitor_request_id == msg.id): # Reply to our "monitor" request. try: + # A non-zero change_seqno means we have downloaded the + # database before, so this reply repopulates the replica + # after a reconnect rather than being the initial + # download. Compute this before the increment below. + reconnect = self.has_ever_connected() self.change_seqno += 1 self._monitor_request_id = None if (self.state == @@ -494,15 +565,23 @@ class Idl(object): # If 'found' is false, clear table rows for new dump if not msg.result[0]: self.__clear() - self.__parse_update(msg.result[2], OVSDB_UPDATE3) + self.__parse_update(msg.result[2], OVSDB_UPDATE3, + reconnect=reconnect) + else: + # 'found' is true: the server sent an incremental + # update since last_id, already reconciled, so + # deliver it through notify() as usual. + self.__parse_update(msg.result[2], OVSDB_UPDATE3) self.last_id = msg.result[1] elif self.state == self.IDL_S_DATA_MONITOR_COND_REQUESTED: self.__clear() - self.__parse_update(msg.result, OVSDB_UPDATE2) + self.__parse_update(msg.result, OVSDB_UPDATE2, + reconnect=reconnect) else: assert self.state == self.IDL_S_DATA_MONITOR_REQUESTED self.__clear() - self.__parse_update(msg.result, OVSDB_UPDATE) + self.__parse_update(msg.result, OVSDB_UPDATE, + reconnect=reconnect) self.state = self.IDL_S_MONITORING except error.Error as e: @@ -773,6 +852,11 @@ class Idl(object): def notify(self, event, row, updates=None): """Hook for implementing create/update/delete notifications + This is called once per changed row for incremental updates and for + the initial database download. On reconnect the changes are instead + delivered to notify_reconnect(), so that the redundant DELETE+CREATE + churn from rebuilding the local replica can be reconciled first. + :param event: The event that was triggered :type event: ROW_CREATE, ROW_UPDATE, or ROW_DELETE :param row: The row as it is after the operation has occured @@ -782,6 +866,40 @@ class Idl(object): :type updates: Row """ + def notify_reconnect(self, notices): + """Hook for delivering reconciled changes after a reconnect + + On reconnect the IDL discards its local replica and repopulates it + from a fresh monitor dump, which would otherwise appear as a + DELETE+CREATE for every row. Rather than replaying that churn, the + changes that actually occurred while the IDL was disconnected are + collected, reconciled, and delivered through this hook. + + The initial database download and all incremental updates still flow + through notify() as usual; this hook is only used for the + post-reconnect monitor dump. + + The default implementation forwards each reconciled notice to + notify(), so that a subclass which only overrides notify() still + receives post-reconnect changes -- reconciled (rows that did not + change are suppressed, modified rows arrive as a single ROW_UPDATE) + rather than as a create-for-every-row storm. Override this hook to + receive the whole reconciled set in one call instead; doing so + suppresses the per-row notify() calls for the reconnect dump. + + :param notices: A ReconciledNotices view. Iterating it yields one + Notice per row that actually changed while the IDL + was disconnected: a ROW_UPDATE (with only the changed + columns in its 'updates') for a modified row, a + ROW_DELETE for a row that went away, or a ROW_CREATE + for a row that appeared. Rows that survived the + reconnect unchanged are suppressed. The view can + also be indexed by row UUID. + :type notices: ReconciledNotices + """ + for notice in notices: + self.notify(*notice) + def cooperative_yield(self): """Hook for cooperatively yielding to eventlet/gevent/asyncio/etc. @@ -797,6 +915,14 @@ class Idl(object): for table in self.tables.values(): if table.rows: changed = True + # Record a DELETE notice for every row being discarded. The + # subsequent monitor dump records a matching CREATE for rows + # that still exist; _flush_notices() reconciles the pairs on + # reconnect. On a first connect the tables are empty, so this + # records nothing. + for row_uuid, row in table.rows.items(): + self._pending_notices[row_uuid].append( + Notice(ROW_DELETE, row)) table.rows.clear() self.cond_seqno = 0 @@ -806,12 +932,16 @@ class Idl(object): def __update_has_lock(self, new_has_lock): if new_has_lock and not self.has_lock: - if self._monitor_request_id is None: + if self.state == self.IDL_S_MONITORING: self.change_seqno += 1 else: - # We're waiting for a monitor reply, so don't signal that the - # database changed. The monitor reply will increment - # change_seqno anyhow. + # We haven't finished downloading the database yet, so don't + # signal that the database changed. The monitor reply will + # increment change_seqno anyhow. Gating on the MONITORING + # state (rather than merely on no monitor request being in + # flight) keeps change_seqno at 0 through the initial + # download, so that has_ever_connected() reliably + # distinguishes a first connect from a reconnect. pass self.is_lock_contended = False self.has_lock = new_has_lock @@ -919,7 +1049,7 @@ class Idl(object): self._server_monitor_request_id = msg.id self.send_request(msg) - def __parse_update(self, update, version, tables=None): + def __parse_update(self, update, version, tables=None, reconnect=False): try: if not tables: self.__do_parse_update(update, version, self.tables) @@ -928,13 +1058,38 @@ class Idl(object): except error.Error as e: vlog.err("%s: error parsing update: %s" % (self._session.get_name(), e)) + # Drop any notices buffered before the error rather than + # delivering a partial, inconsistent update. + self._pending_notices.clear() + return + self._flush_notices(reconnect) + + def _flush_notices(self, reconnect): + """Deliver the notices buffered while parsing a monitor reply. + + On reconnect the buffered per-row DELETE (from __clear()) and CREATE + events are reconciled and handed to notify_reconnect() in a single + call. Otherwise (initial download and incremental updates) each + buffered notice is delivered individually through notify(). + """ + if reconnect: + # Hand the buffered notices to the view and start a fresh buffer, + # so the (lazy) ReconciledNotices remains valid even if the + # callback holds on to it past this call. + pending = self._pending_notices + self._pending_notices = collections.defaultdict(list) + self.notify_reconnect(ReconciledNotices(pending)) + else: + for events in self._pending_notices.values(): + for notice in events: + self.notify(*notice) + self._pending_notices.clear() def __do_parse_update(self, table_updates, version, tables): if not isinstance(table_updates, dict): raise error.Error("<table-updates> is not an object", table_updates) - notices = [] for table_name, table_update in table_updates.items(): table = tables.get(table_name) if not table: @@ -964,7 +1119,7 @@ class Idl(object): if version in (OVSDB_UPDATE2, OVSDB_UPDATE3): changes = self.__process_update2(table, uuid, row_update) if changes and tables is not self.server_tables: - notices.append(changes) + self._pending_notices[uuid].append(changes) self.change_seqno += 1 continue @@ -979,10 +1134,8 @@ class Idl(object): changes = self.__process_update(table, uuid, old, new) if changes and tables is not self.server_tables: - notices.append(changes) + self._pending_notices[uuid].append(changes) self.change_seqno += 1 - for notice in notices: - self.notify(*notice) def __process_update2(self, table, uuid, row_update): """Returns Notice if a column changed, False otherwise.""" diff --git a/python/ovs/tests/test_reconciled_notices.py b/python/ovs/tests/test_reconciled_notices.py new file mode 100644 index 000000000..df099e949 --- /dev/null +++ b/python/ovs/tests/test_reconciled_notices.py @@ -0,0 +1,255 @@ +import collections +import uuid + +import pytest + +from ovs.db.idl import ( + Notice, + ReconciledNotices, + ROW_CREATE, + ROW_DELETE, + ROW_UPDATE, + Row, +) + + +class FakeTable: + def __init__(self, column_names): + self.columns = {name: None for name in column_names} + + +class FakeDatum: + """Minimal Datum-like object that supports equality comparison.""" + def __init__(self, value): + self.value = value + + def __eq__(self, other): + if isinstance(other, FakeDatum): + return self.value == other.value + return NotImplemented + + def __repr__(self): + return "FakeDatum(%r)" % self.value + + +def make_row(table, row_uuid, data): + return Row(None, table, row_uuid, data) + + +def make_notices(notice_list): + """Build defaultdict(list) keyed by UUID from (uuid, Notice) pairs.""" + raw = collections.defaultdict(list) + for row_uuid, notice in notice_list: + raw[row_uuid].append(notice) + return raw + + +TABLE = FakeTable(["name", "value", "tag"]) + + [email protected]( + "event", + [ROW_CREATE, ROW_DELETE, ROW_UPDATE], +) +def test_single_event_passthrough(event): + row_uuid = uuid.uuid4() + row = make_row(TABLE, row_uuid, {"name": FakeDatum("r1")}) + if event == ROW_UPDATE: + updates = make_row(TABLE, row_uuid, {"name": FakeDatum("old")}) + notice = Notice(event, row, updates) + else: + notice = Notice(event, row) + raw = make_notices([(row_uuid, notice)]) + + result = list(ReconciledNotices(raw)) + + assert len(result) == 1 + assert result[0] is notice + + [email protected]( + "old_data,new_data", + [ + ( + {"name": FakeDatum("same")}, + {"name": FakeDatum("same")}, + ), + ( + {"name": FakeDatum("n"), "value": FakeDatum(42), + "tag": FakeDatum("t")}, + {"name": FakeDatum("n"), "value": FakeDatum(42), + "tag": FakeDatum("t")}, + ), + ], +) +def test_delete_create_unchanged_suppressed(old_data, new_data): + row_uuid = uuid.uuid4() + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, + make_row(TABLE, row_uuid, old_data))), + (row_uuid, Notice(ROW_CREATE, + make_row(TABLE, row_uuid, new_data))), + ]) + + result = list(ReconciledNotices(raw)) + + assert len(result) == 0 + + +def test_delete_create_unchanged_getitem_returns_none(): + row_uuid = uuid.uuid4() + data = {"name": FakeDatum("same")} + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, + make_row(TABLE, row_uuid, dict(data)))), + (row_uuid, Notice(ROW_CREATE, + make_row(TABLE, row_uuid, dict(data)))), + ]) + + assert ReconciledNotices(raw)[row_uuid] is None + + [email protected]( + "old_data,new_data,expected_changed_cols", + [ + ( + {"name": FakeDatum("old_val")}, + {"name": FakeDatum("new_val")}, + {"name"}, + ), + ( + {"name": FakeDatum("n"), "value": FakeDatum(1), + "tag": FakeDatum("t")}, + {"name": FakeDatum("n"), "value": FakeDatum(2), + "tag": FakeDatum("t")}, + {"value"}, + ), + ( + {"name": FakeDatum("a"), "value": FakeDatum(1)}, + {"name": FakeDatum("b"), "value": FakeDatum(2)}, + {"name", "value"}, + ), + ], +) +def test_delete_create_changed_becomes_update(old_data, new_data, + expected_changed_cols): + row_uuid = uuid.uuid4() + old_row = make_row(TABLE, row_uuid, old_data) + new_row = make_row(TABLE, row_uuid, new_data) + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, old_row)), + (row_uuid, Notice(ROW_CREATE, new_row)), + ]) + + result = list(ReconciledNotices(raw)) + + assert len(result) == 1 + notice = result[0] + assert notice.event == ROW_UPDATE + assert notice.row is new_row + assert set(notice.updates._data.keys()) == expected_changed_cols + for col in expected_changed_cols: + assert notice.updates._data[col] == old_data[col] + + +def test_update_row_has_correct_uuid_and_table(): + row_uuid = uuid.uuid4() + old_row = make_row(TABLE, row_uuid, {"name": FakeDatum("old")}) + new_row = make_row(TABLE, row_uuid, {"name": FakeDatum("new")}) + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, old_row)), + (row_uuid, Notice(ROW_CREATE, new_row)), + ]) + + result = list(ReconciledNotices(raw)) + + assert result[0].updates.uuid == row_uuid + assert result[0].updates._table is TABLE + + [email protected]( + "old_data,new_data", + [ + ( + {"name": FakeDatum("n"), "value": FakeDatum("x")}, + {"name": FakeDatum("n")}, + ), + ( + {"name": FakeDatum("n")}, + {"name": FakeDatum("n"), "value": FakeDatum("x")}, + ), + ], +) +def test_column_only_in_one_row_not_compared(old_data, new_data): + row_uuid = uuid.uuid4() + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, + make_row(TABLE, row_uuid, old_data))), + (row_uuid, Notice(ROW_CREATE, + make_row(TABLE, row_uuid, new_data))), + ]) + + result = list(ReconciledNotices(raw)) + + assert len(result) == 0 + + +def test_mixed_rows(): + uuid_new = uuid.uuid4() + uuid_del = uuid.uuid4() + uuid_unchanged = uuid.uuid4() + uuid_changed = uuid.uuid4() + + row_new = make_row(TABLE, uuid_new, {"name": FakeDatum("new_row")}) + row_del = make_row(TABLE, uuid_del, {"name": FakeDatum("gone")}) + row_unch_old = make_row(TABLE, uuid_unchanged, {"name": FakeDatum("s")}) + row_unch_new = make_row(TABLE, uuid_unchanged, {"name": FakeDatum("s")}) + row_chg_old = make_row(TABLE, uuid_changed, {"name": FakeDatum("before")}) + row_chg_new = make_row(TABLE, uuid_changed, {"name": FakeDatum("after")}) + + raw = make_notices([ + (uuid_new, Notice(ROW_CREATE, row_new)), + (uuid_del, Notice(ROW_DELETE, row_del)), + (uuid_unchanged, Notice(ROW_DELETE, row_unch_old)), + (uuid_unchanged, Notice(ROW_CREATE, row_unch_new)), + (uuid_changed, Notice(ROW_DELETE, row_chg_old)), + (uuid_changed, Notice(ROW_CREATE, row_chg_new)), + ]) + + results = {n.row.uuid: n for n in ReconciledNotices(raw)} + + assert len(results) == 3 + assert results[uuid_new].event == ROW_CREATE + assert results[uuid_del].event == ROW_DELETE + assert results[uuid_changed].event == ROW_UPDATE + assert uuid_unchanged not in results + + +def test_all_rows_suppressed(): + uuids = [uuid.uuid4() for _ in range(3)] + raw = make_notices( + [(u, Notice(ROW_DELETE, make_row(TABLE, u, {"name": FakeDatum("x")}))) + for u in uuids] + + [(u, Notice(ROW_CREATE, + make_row(TABLE, u, {"name": FakeDatum("x")}))) + for u in uuids] + ) + + assert list(ReconciledNotices(raw)) == [] + + +def test_empty_notices(): + assert list(ReconciledNotices(collections.defaultdict(list))) == [] + + +def test_unexpected_event_count_asserts(): + row_uuid = uuid.uuid4() + row = make_row(TABLE, row_uuid, {"name": FakeDatum("x")}) + raw = make_notices([ + (row_uuid, Notice(ROW_DELETE, row)), + (row_uuid, Notice(ROW_CREATE, row)), + (row_uuid, Notice(ROW_UPDATE, row)), + ]) + + with pytest.raises(AssertionError, match="unexpected number of events"): + list(ReconciledNotices(raw)) diff --git a/tests/ovsdb-idl.at b/tests/ovsdb-idl.at index cd534e09d..41b399cc8 100644 --- a/tests/ovsdb-idl.at +++ b/tests/ovsdb-idl.at @@ -2099,13 +2099,32 @@ OVSDB_CHECK_IDL_NOTIFY([simple idl verify notify], 012: table simple: i=-1 r=125 b=false s=newstring u=<2> ia=[1] ra=[1.5] ba=[false] sa=[] ua=[] uuid=<6> 012: table simple: i=1 r=123.5 b=false s=mystring u=<3> ia=[1 2 3] ra=[-0.5] ba=[true] sa=[abc def] ua=[<4> <5>] uuid=<0> 013: reconnect -014: event:create, row={i=-1 r=125 b=false s=newstring u=<2> ia=[1] ra=[1.5] ba=[false] sa=[] ua=[]}, uuid=<6>, updates=None -014: event:create, row={i=1 r=123.5 b=false s=mystring u=<3> ia=[1 2 3] ra=[-0.5] ba=[true] sa=[abc def] ua=[<4> <5>]}, uuid=<0>, updates=None 014: table simple: i=-1 r=125 b=false s=newstring u=<2> ia=[1] ra=[1.5] ba=[false] sa=[] ua=[] uuid=<6> 014: table simple: i=1 r=123.5 b=false s=mystring u=<3> ia=[1 2 3] ra=[-0.5] ba=[true] sa=[abc def] ua=[<4> <5>] uuid=<0> 015: done ]]) +dnl When a client overrides notify_reconnect(), the post-reconnect monitor +dnl dump is delivered there (reconciled) instead of as per-row notify() +dnl calls. A row that is unchanged across the reconnect reconciles away, so +dnl notify_reconnect() receives an empty set and notify() is not called. +OVSDB_CHECK_IDL_PY([simple idl, notify_reconnect override], [], + [['track-notify-reconnect' \ + '["idltest", + {"op": "insert", + "table": "simple", + "row": {"i": 1, "s": "mystring"}}]' \ + 'reconnect']], + [[000: empty +001: {"error":null,"result":[{"uuid":["uuid","<0>"]}]} +002: event:create, row={i=1 r=0 b=false s=mystring u=<1> ia=[] ra=[] ba=[] sa=[] ua=[]}, uuid=<0>, updates=None +002: table simple: i=1 r=0 b=false s=mystring u=<1> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<0> +003: reconnect +004: notify_reconnect: +004: table simple: i=1 r=0 b=false s=mystring u=<1> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<0> +005: done +]], [notify]) + OVSDB_CHECK_IDL_NOTIFY([indexed idl, modification and removal notify], [['track-notify' \ '["idltest", diff --git a/tests/test-ovsdb.py b/tests/test-ovsdb.py index 394897648..49e35abef 100644 --- a/tests/test-ovsdb.py +++ b/tests/test-ovsdb.py @@ -758,6 +758,7 @@ def update_condition(idl, commands, step): def do_idl(schema_file, remote, *commands): schema_helper = ovs.db.idl.SchemaHelper(schema_file) track_notify = False + track_notify_reconnect = False if remote.startswith("ssl:"): if len(commands) < 3: @@ -774,6 +775,11 @@ def do_idl(schema_file, remote, *commands): commands = commands[1:] track_notify = True + if commands and commands[0] == "track-notify-reconnect": + commands = commands[1:] + track_notify = True + track_notify_reconnect = True + if commands and commands[0].startswith("?"): readonly = {} for x in commands[0][1:].split("?"): @@ -831,8 +837,26 @@ def do_idl(schema_file, remote, *commands): sys.stdout.write(output) sys.stdout.flush() + def mock_notify_reconnect(notices): + output = "%03d: notify_reconnect:\n" % step + for notice in notices: + output += "%03d: event:%s, row={%s}, %s, updates=" % ( + step, str(notice.event), + get_simple_table_printable_row(notice.row, 'l2', 'l1'), + get_simple_printable_row_string(notice.row, ["uuid"])) + if notice.updates is None: + output += "None" + else: + output += "{" + get_simple_table_printable_row( + notice.updates) + "}" + output += "\n" + sys.stdout.write(output) + sys.stdout.flush() + if track_notify and "simple" in idl.tables: idl.notify = mock_notify + if track_notify_reconnect: + idl.notify_reconnect = mock_notify_reconnect commands = list(commands) if len(commands) >= 1 and "condition" in commands[0]: -- 2.55.0 _______________________________________________ dev mailing list [email protected] https://mail.openvswitch.org/mailman/listinfo/ovs-dev
