# # The contents of this file are subject to the terms of the Common Development and # Distribution License (the License). You may not use this file except in compliance with the # License. # # You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the # specific language governing permission and limitations under the License. # # When distributing Covered Software, include this CDDL Header Notice in each file and include # the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL # Header, with the fields enclosed by brackets [] replaced by your own identifying # information: "Portions copyright [year] [name of copyright owner]". # # Copyright 2026 3A Systems, LLC. """Finds the changes a replication server's changelog lacks (#1090, #1195). A directory server numbers the changes it makes one after the other: CSNGenerator.newCSN() adds one to the sequence number of the CSN for every change, whatever its time. So the changes of one replica in a changelog have no gap in their sequence numbers unless a change was never published - an operation that failed after it had its CSN, or one that was lost on its way to the replication servers. During the load of the convergence run no operation fails (load.sh checks that), so a gap there is a lost change: a change the data of the other replicas misses, while their ServerState and missing-changes say nothing, since both only know the newest CSN. Usage: changelog-gaps.py [] Without a directory it reads the changelogDb as a tar stream on its standard input, as "docker cp :/opt/opendj/data/changelogDb -" writes it. It reports, for every replica of the domain of , the changes whose CSN time is between and , and the sequence numbers missing between them. Exit code 1: a change is missing; 2: the changelog cannot be read (no such domain, or it is encrypted). What it cannot tell. A change lost at either end of a replica's changes in the window - its last ones, or its first ones when no earlier change of the replica is in the changelog - leaves no gap between two changes that are there; the data of the other replicas shows such a change unless a later change of another server overwrites it. And not every sequence number that no record holds is a lost change: a directory server that goes offline (a restart, its domain disabled) numbers its ReplicaOfflineMsg like a change, and the changelog keeps only the newest of them, in the offline.state of the replica, not in a log file. The convergence run restarts no server after the topology is up; a run that did would read each restart in the window as a lost change. The file format is the one of FileReplicaDB: every log file is cut into blocks of 256 bytes, and every block after the first starts with a 4-byte offset (BlockLogWriter); in between, a record is its length in 4 bytes and the bytes of its UpdateMsg, whose header is the message type, the protocol version and the CSN as 28 hexadecimal characters (LDAPUpdateMsg). """ import os import re import sys import tarfile BLOCK_SIZE = 256 SIZE_OF_BLOCK_OFFSET = 4 # ReplicationMsg: MSG_TYPE_MODIFY, ADD, DELETE, MODIFYDN of the protocol versions after 1 UPDATE_TYPES = {22: "modify", 23: "add", 24: "delete", 25: "modifydn"} DISK_ENCODING = 0xFF CSN = re.compile(rb"[0-9a-f]{28}") LOG_FILE = re.compile(r"(?:^|/)(\d+)\.dom/(\d+)\.server/[^/.][^/]*\.log$") def records(data): """The UpdateMsg bytes of the records of one log file.""" stream = bytearray(data[:BLOCK_SIZE]) for start in range(BLOCK_SIZE, len(data), BLOCK_SIZE): stream += data[start + SIZE_OF_BLOCK_OFFSET:start + BLOCK_SIZE] pos = 0 while pos + 4 <= len(stream): length = int.from_bytes(stream[pos:pos + 4], "big") if length <= 0 or pos + 4 + length > len(stream): raise ValueError("record of %d bytes at %d of %d" % (length, pos, len(stream))) yield bytes(stream[pos + 4:pos + 4 + length]) pos += 4 + length def read_changelog(entries): """(domains, changes): domain id by base DN, and for each (domain id, server id) a list of (sequence number, time in ms, type).""" domains = {} changes = {} for name, data in entries: if os.path.basename(name) == "domains.state": for line in data.decode("utf-8").splitlines(): if ":" in line: domain_id, dn = line.split(":", 1) domains[dn.strip().lower()] = domain_id continue match = LOG_FILE.search(name) if not match: continue found = changes.setdefault((match.group(1), int(match.group(2))), []) for message in records(data): if message[0] == DISK_ENCODING: raise EncryptedChangelog(name) if message[0] not in UPDATE_TYPES or not CSN.fullmatch(message[2:30]): continue csn = message[2:30].decode("ascii") found.append((int(csn[20:28], 16), int(csn[0:16], 16), UPDATE_TYPES[message[0]])) return domains, changes class EncryptedChangelog(Exception): pass def from_directory(root): for directory, _, files in os.walk(root): for file in files: path = os.path.join(directory, file) with open(path, "rb") as f: yield os.path.relpath(path, root), f.read() def from_tar(stream): with tarfile.open(fileobj=stream, mode="r|") as tar: for member in tar: if member.isfile(): yield member.name, tar.extractfile(member).read() def main(argv): if len(argv) not in (4, 5): sys.stderr.write(__doc__) return 2 base_dn, start, end = argv[1].lower(), int(argv[2]), int(argv[3]) entries = from_directory(argv[4]) if len(argv) == 5 else from_tar(sys.stdin.buffer) try: domains, changes = read_changelog(entries) except EncryptedChangelog as e: print("the changelog is encrypted (%s): its changes cannot be read" % e) return 2 except (tarfile.TarError, ValueError, OSError) as e: print("the changelog cannot be read: %s" % e) return 2 if base_dn not in domains: print("no domain %s in the changelog, which holds %s" % (base_dn, sorted(domains))) return 2 lost = False for (domain_id, server_id), found in sorted(changes.items()): if domain_id != domains[base_dn]: continue load = sorted((seqnum, time, kind) for seqnum, time, kind in found if start <= time <= end) if not load: continue seqnums = [seqnum for seqnum, _, _ in load] missing = [] for previous, following in zip(load, load[1:]): missing += [(seqnum, previous, following) for seqnum in range(previous[0] + 1, following[0])] print("replica %d: %d changes in the load, sequence numbers %d to %d, %d missing" % (server_id, len(load), seqnums[0], seqnums[-1], len(missing))) for seqnum, previous, following in missing[:20]: print(" missing %d, between the %s %d (%d) and the %s %d (%d)" % (seqnum, previous[2], previous[0], previous[1], following[2], following[0], following[1])) if len(missing) > 20: print(" and %d more" % (len(missing) - 20)) lost = lost or bool(missing) return 1 if lost else 0 if __name__ == "__main__": sys.exit(main(sys.argv))