#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import sys
import time

import lib.args
import lib.base
import lib.db_sqlite
import lib.disk
import lib.human
import lib.lftest
import lib.shell
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = """
Linuxfabrik GmbH, Zurich/Switzerland;
enhanced by Claudio Kuenzler
"""
__version__ = '2026102702'

DESCRIPTION = """Monitors a Valkey server via the INFO command. Reports memory usage, fragmentation
ratio, keyspace hit rate, connected clients, replication status, and persistence state.
Alerts on memory consumption, high fragmentation, low hit rates, and OS-level
misconfigurations such as overcommit and transparent huge pages. Includes Valkey Memory
Doctor diagnostics when available."""

DEFAULT_CACERT = '/etc/pki/tls/certs/rootCA.pem'
DEFAULT_CRIT = None
DEFAULT_HOSTNAME = '127.0.0.1'
DEFAULT_IGNORE_MAXMEMORY0 = False
DEFAULT_IGNORE_OVERCOMMIT = False
DEFAULT_IGNORE_SOMAXCONN = False
DEFAULT_IGNORE_SYNCPARTIALERR = False
DEFAULT_IGNORE_THP = False
DEFAULT_PORT = '6379'
DEFAULT_TIMEOUT = 8
DEFAULT_VERBOSE = False
DEFAULT_WARN = 90


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--cacert',
        help='CA certificate file for TLS verification. '
        'Requires `--tls`. '
        'Default: %(default)s',
        dest='CACERT',
        default=DEFAULT_CACERT,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for memory usage as a percentage. Default: >= %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '-H',
        '--hostname',
        help='Valkey server hostname. Default: %(default)s',
        dest='HOSTNAME',
        default=DEFAULT_HOSTNAME,
    )

    parser.add_argument(
        '--ignore-maxmemory0',
        help='Suppress the warning when Valkey maxmemory is set to 0 (unlimited).',
        dest='IGNORE_MAXMEMORY0',
        default=DEFAULT_IGNORE_MAXMEMORY0,
        action='store_true',
    )

    parser.add_argument(
        '--ignore-overcommit',
        help='Suppress the warning when vm.overcommit_memory is not set to 1.',
        dest='IGNORE_OVERCOMMIT',
        default=DEFAULT_IGNORE_OVERCOMMIT,
        action='store_true',
    )

    parser.add_argument(
        '--ignore-somaxconn',
        help='Suppress the warning when net.core.somaxconn is lower than '
        'net.ipv4.tcp_max_syn_backlog.',
        dest='IGNORE_SOMAXCONN',
        default=DEFAULT_IGNORE_SOMAXCONN,
        action='store_true',
    )

    parser.add_argument(
        '--ignore-sync-partial-err',
        help='Suppress the warning when replicas were denied a partial resync since '
        'the last check and fell back to a full sync.',
        dest='IGNORE_SYNCPARTIALERR',
        default=DEFAULT_IGNORE_SYNCPARTIALERR,
        action='store_true',
    )

    parser.add_argument(
        '--ignore-thp',
        help='Suppress the warning about transparent huge pages being set to "always".',
        dest='IGNORE_THP',
        default=DEFAULT_IGNORE_THP,
        action='store_true',
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-p',
        '--password',
        help='Password for Valkey server authentication.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--port',
        help='Valkey server port. Default: %(default)s',
        dest='PORT',
        type=int,
        default=DEFAULT_PORT,
    )

    parser.add_argument(
        '--socket',
        help='Valkey server Unix socket path. Overrides --hostname and --port.',
        dest='SOCKET',
        default=None,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '--tls',
        help='Establish a secure TLS connection to Valkey.',
        dest='TLS',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--username',
        help='Username for Valkey server authentication.',
        dest='USERNAME',
    )

    parser.add_argument(
        '--verbose',
        help=lib.args.help('--verbose'),
        dest='VERBOSE',
        action='store_true',
        default=DEFAULT_VERBOSE,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for memory usage as a percentage. Default: >= %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def run_cli(cmd, env, args):
    """Run a `valkey-cli` command line and return `(stdout, stderr, retc)`. A command
    that does not finish within `--timeout` is a WARN, any other failure to start it
    UNKNOWN.
    """
    # all calls of a run share --timeout, so two of them cannot outlast the command
    # timeout of the monitoring server
    remaining = max(1, round(args.DEADLINE - time.monotonic()))
    success, result = lib.shell.shell_exec(cmd, env=env, timeout=remaining)
    if not success:
        if result.startswith('Timeout after'):
            target = args.SOCKET or f'{args.HOSTNAME}:{args.PORT}'
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while connecting to Valkey at {target}.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        # the message names the program, never its arguments
        lib.base.cu(result)
    return result


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)
    args.DEADLINE = time.monotonic() + args.TIMEOUT

    # build the base x-cli command line
    base_cmd = ['valkey-cli']
    if not args.SOCKET:
        base_cmd += ['-h', args.HOSTNAME, '-p', str(args.PORT)]
    else:
        base_cmd += ['-s', args.SOCKET]
    env = None
    if args.PASSWORD:
        # The password goes through the environment instead of argv: everybody on the
        # host can read a command line (`ps`), the environment only the same user and
        # root (`/proc/<pid>/environ`). `valkey-cli` up to 8.1 reads `REDISCLI_AUTH`,
        # from 9.0 on `VALKEYCLI_AUTH` with `REDISCLI_AUTH` as fallback. Setting both
        # covers every release and keeps an inherited `VALKEYCLI_AUTH` from winning.
        # `-a` wins over both, `--user` combines with them into
        # `AUTH <user> <password>`. Verified against valkey 7.2.5 - 9.1 source
        # (src/valkey-cli.c, parseEnv() and cliAuth()).
        base_cmd.append('--no-auth-warning')
        if args.USERNAME:
            base_cmd += ['--user', args.USERNAME]
        env = {'REDISCLI_AUTH': args.PASSWORD, 'VALKEYCLI_AUTH': args.PASSWORD}
    if args.TLS:
        base_cmd += ['--tls', '--cacert', args.CACERT]

    # fetch data using `x-cli info default`
    if args.TEST is None:
        stdout, stderr, retc = run_cli([*base_cmd, 'info', 'default'], env, args)
        # do not echo the full cmd, it may contain the password
        # a refused login is judged below: redis-cli 6.2 exits 1 on it, 7.4 exits 0
        if retc != 0 and 'AUTH failed' not in stderr:
            lib.base.oao(
                f'`valkey-cli info default` failed (exit {retc}): {stderr.strip()}',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
    else:
        # do not call the command, put in test data
        stdout, stderr, _ = lib.lftest.test(args.TEST)

    # Reaching the server is not logging in. `valkey-cli` exits 0 when the server
    # refuses the login: a missing password leaves `NOAUTH Authentication required.`
    # on stdout, a wrong one adds `AUTH failed: WRONGPASS ...` on stderr (verified
    # with `redis-cli` 6.2 and 7.4 and `valkey-cli` 8). A check that cannot log in says
    # nothing about the server, so it is UNKNOWN instead of a warning about it.
    if 'AUTH failed' in stderr:
        lib.base.cu(
            'Failed to authenticate: Valkey rejected the username or password.'
            ' Check `--username` and `--password`.'
        )
    if stdout.startswith('NOAUTH'):
        lib.base.cu(
            'Failed to authenticate: Valkey requires a password. Pass it with'
            ' `--password` (and `--username` for an ACL user).'
        )

    # connection problems, for example "RR max number of clients reached"
    if not stdout.startswith('# Server'):
        lib.base.oao(stdout, STATE_WARN, always_ok=args.ALWAYS_OK)

    # parse the output, keeping the raw INFO answer for --verbose
    info = stdout
    lines = stdout.splitlines()
    result = {}
    for line in lines:
        try:
            key, value = line.split(':')
            result[key] = value.strip()
            try:
                result[key] = float(result[key])
            except Exception:
                continue
        except Exception:
            continue

    # fetch data using `valkey-cli memory doctor`
    result['memory_doctor'] = 'Hi Sam'
    if args.TEST is None:
        stdout, stderr, retc = run_cli([*base_cmd, 'memory', 'doctor'], env, args)
        # do not echo the full cmd, it may contain the password
        if retc != 0:
            lib.base.oao(
                f'`valkey-cli memory doctor` failed (exit {retc}): {stderr.strip()}',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        if not stdout.startswith('ERR '):
            result['memory_doctor'] = stdout.strip()

    # Convert the cumulative INFO counters to per-second rates against the
    # previous run, stored in this plugin's own SQLite cache, instead of
    # emitting them as continuous counters (issue #320). CPU times are
    # cumulative seconds; they are stored as microseconds so the integer cache
    # keeps sub-second precision, then turned into a CPU percentage.
    stats_counters = {
        'evicted_keys': int(result['evicted_keys']),
        'expired_keys': int(result['expired_keys']),
        'keyspace_hits': int(result['keyspace_hits']),
        'keyspace_misses': int(result['keyspace_misses']),
        'rejected_connections': int(result['rejected_connections']),
        'sync_full': int(result['sync_full']),
        'sync_partial_err': int(result['sync_partial_err']),
        'sync_partial_ok': int(result['sync_partial_ok']),
        'total_commands_processed': int(result['total_commands_processed']),
        'total_connections_received': int(result['total_connections_received']),
        'total_net_input_bytes': int(result['total_net_input_bytes']),
        'total_net_output_bytes': int(result['total_net_output_bytes']),
    }
    cpu_counters = {
        'used_cpu_sys': int(float(result['used_cpu_sys']) * 1_000_000),
        'used_cpu_sys_children': int(
            float(result['used_cpu_sys_children']) * 1_000_000
        ),
        'used_cpu_user': int(float(result['used_cpu_user']) * 1_000_000),
        'used_cpu_user_children': int(
            float(result['used_cpu_user_children']) * 1_000_000
        ),
    }
    # One baseline per server: two instances on the same host would otherwise compute
    # their rates from each other's counters. In test mode, no state is kept, so the
    # tests do not depend on the order they run in.
    rates = None
    if args.TEST is None:
        rates = lib.db_sqlite.per_second_deltas(
            'linuxfabrik-monitoring-plugins-valkey-status.db',
            args.SOCKET or f'{args.HOSTNAME}:{args.PORT}',
            {**stats_counters, **cpu_counters},
        )

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''

    # analyze data
    # Valkey v8.0.3 (based on Redis v7.2.4), standalone mode on 127.0.0.1:6379,
    if 'valkey_version' not in result:
        lib.base.cu('This is not a Valkey server.')

    # build the message
    msg += f'Valkey v{result["valkey_version"]}'
    if 'redis_version' in result:
        msg += f' (based on Redis v{result["redis_version"]})'
    if 'server_mode' in result:
        msg += f', {result["server_mode"]} mode '
    if not args.SOCKET:
        msg += f'on {args.HOSTNAME}:{int(result["tcp_port"])}, '
    else:
        msg += 'connected via socket, '
    # /etc/valkey.conf, up 1h 39m
    # a server started without a configuration file reports an empty config_file
    if result.get('config_file'):
        msg += f'{result["config_file"]}, '
    msg += f'up {lib.human.seconds2human(result["uptime_in_seconds"])}, '

    # 61.0% memory usage (5.8MiB/9.5MiB, 10.0MiB peak, 17.7MiB RSS), maxmemory-policy=noeviction
    if result['maxmemory'] != 0:
        # fixed memory size
        mem_usage = round(
            float(result['used_memory']) / float(result['maxmemory']) * 100,
            1,
        )
    else:
        # maxmemory = 0 (dangerous)
        mem_usage = round(
            float(result['used_memory']) / float(result['total_system_memory']) * 100,
            1,
        )
        msg += 'unlimited memory usage enabled'
        if not args.IGNORE_MAXMEMORY0:
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        msg += ', '
    mem_state = lib.base.get_state(mem_usage, args.WARN, args.CRIT)
    state = lib.base.get_worst(state, mem_state)
    msg += f'{mem_usage}% memory usage{lib.base.state2str(mem_state, prefix=" ")} ('
    msg += f'{lib.human.bytes2human(result["used_memory"])}/'
    if result['maxmemory'] != 0:
        msg += lib.human.bytes2human(result['maxmemory'])
    else:
        msg += lib.human.bytes2human(result['total_system_memory'])
    msg += ', '
    msg += f'{lib.human.bytes2human(result["used_memory_peak"])} peak, '
    msg += f'{lib.human.bytes2human(result["used_memory_rss"])} RSS), '
    msg += f'maxmemory-policy={result.get("maxmemory_policy", "N/A")}, '

    # 1 DB (db0) with 4 keys, 0.0 evicted keys, 0.0 expired keys, hit rate 100.0%
    # (6.0M hits, 0.0 misses)
    # search for {'db0': 'keys=20489,expires=20489,avg_ttl=47954'}
    db_count = 0
    key_count = 0
    db_names = ''
    for key, value in result.items():
        try:
            if value.startswith('keys='):
                # we found a database section. Read it as key=value pairs: Redis 7.4
                # appends `subexpiry=`, Valkey `keys_with_volatile_items=`, and a fixed
                # split into three dropped the whole database (Redis src/server.c,
                # Valkey src/server.c, genRedisInfoString()/genValkeyInfoString()).
                fields = dict(item.split('=', 1) for item in value.split(','))
                keys = int(fields['keys'])
                expires = fields['expires']
                avg_ttl = fields['avg_ttl']
                db_count += 1
                db_names += key + ' '
                key_count += keys
                perfdata += lib.base.get_perfdata(
                    f'keyspace_{key}_keys',
                    keys,
                    uom=None,
                    warn=None,
                    crit=None,
                    _min=0,
                    _max=None,
                )
                perfdata += lib.base.get_perfdata(
                    f'keyspace_{key}_expires',
                    expires,
                    uom=None,
                    warn=None,
                    crit=None,
                    _min=0,
                    _max=None,
                )
                perfdata += lib.base.get_perfdata(
                    f'keyspace_{key}_avg_ttl',
                    float(avg_ttl) / 1000,
                    uom='s',
                    warn=None,
                    crit=None,
                    _min=0,
                    _max=None,
                )
        except Exception:
            continue
    if db_count:
        msg += f'{db_count} {lib.txt.pluralize("DB", db_count)} '
        msg += f'({db_names.strip()}) with {key_count} keys, '
        perfdata += lib.base.get_perfdata(
            'db_count',
            db_count,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'key_count',
            key_count,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
    if result['keyspace_hits'] + result['keyspace_misses'] != 0:
        keyspace_hit_rate = round(
            result['keyspace_hits']
            / (result['keyspace_hits'] + result['keyspace_misses'])
            * 100,
            1,
        )
    else:
        keyspace_hit_rate = 0
    msg += f'{lib.human.number2human(result["evicted_keys"])} evicted '
    msg += f'{lib.txt.pluralize("key", result["evicted_keys"])}, '
    msg += f'{lib.human.number2human(result["expired_keys"])} expired '
    msg += f'{lib.txt.pluralize("key", result["expired_keys"])}, '
    msg += f'hit rate {keyspace_hit_rate}% '
    msg += f'({lib.human.number2human(result["keyspace_hits"])} hits, '
    msg += f'{lib.human.number2human(result["keyspace_misses"])} misses), '

    # check the OS config
    # see https://valkey.io/topics/admin for details

    # vm.overcommit_memory is not set to 1
    overcommit_memory = lib.base.coe(
        lib.disk.read_file('/proc/sys/vm/overcommit_memory')
    )
    overcommit_memory = int(overcommit_memory)
    if overcommit_memory != 1:
        msg += 'vm.overcommit_memory is not set to 1'
        if not args.IGNORE_OVERCOMMIT:
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        msg += ', '

    # kernel transparent_hugepage is not set to "madvise" or "never"
    if not args.IGNORE_THP:
        thp_enabled = lib.base.coe(
            lib.disk.read_file('/sys/kernel/mm/transparent_hugepage/enabled'),
        )
        if '[always]' in thp_enabled:
            # on systems in which kernel transparent huge pages is set to "always",
            # valkey might have latency problems specifically with fork(2) and CoW
            msg += 'kernel transparent_hugepage is not set to "madvise" or "never"'
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
            msg += ', '

    # net.core.somaxconn (128) is lower than net.ipv4.tcp_max_syn_backlog (256)
    somaxconn = lib.base.coe(lib.disk.read_file('/proc/sys/net/core/somaxconn'))
    somaxconn = int(somaxconn)
    tcp_max_syn_backlog = lib.base.coe(
        lib.disk.read_file(
            '/proc/sys/net/ipv4/tcp_max_syn_backlog',
        )
    )
    tcp_max_syn_backlog = int(tcp_max_syn_backlog)
    if 0 < somaxconn < tcp_max_syn_backlog:
        msg += f'net.core.somaxconn ({somaxconn}) is lower than '
        msg += f'net.ipv4.tcp_max_syn_backlog ({tcp_max_syn_backlog})'
        if not args.IGNORE_SOMAXCONN:
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        msg += ', '

    # A replica that lost its connection asks to continue where it left off. Refused,
    # because the replication backlog no longer holds what it missed or because the
    # replication ID changed, it has to fetch a full copy, which forks and transfers a
    # complete dump. The counter covers the time since the server start, so only a
    # rise since the previous run says that it happened now; a single, recovered event
    # would otherwise warn until the next restart. Verified against
    # masterTryPartialResynchronization() in src/replication.c of Redis and Valkey.
    if rates is not None and rates['sync_partial_err'] > 0:
        msg += (
            'replicas were denied a partial resync since the last check and fell back'
            ' to a full sync (if the log says "lack of backlog", raise'
            ' `repl-backlog-size`)'
        )
        if not args.IGNORE_SYNCPARTIALERR:
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        msg += ', '

    # memory doctor

    # Sam, I detected a few issues in this Valkey instance memory implants
    if result['memory_doctor'].startswith('Sam, I detected a few issues'):
        local_state = STATE_WARN
        # have a look at https://github.com/valkey/valkey/blob/unstable/src/object.c

        # * Peak memory: In the past this instance used more than 150% the memory that is
        #   currently using. The allocator is normally not able to release memory after a peak,
        #   so you can expect to see a big fragmentation ratio, however this is actually harmless
        #   and is only due to the memory peak, and if the Valkey instance Resident Set Size
        #   (RSS) is currently bigger than expected, the memory will be used as soon as you fill
        #   the Valkey instance with more data. If the memory peak was only occasional and you
        #   want to try to reclaim memory, please try the MEMORY PURGE command, otherwise
        #   the only other option is to shutdown and restart the instance.\n\n");
        if (
            ' * Peak memory: ' in result['memory_doctor']
            and result['memory_doctor'].count(' * ') == 1
        ):
            # just this one warning, which is harmless, so ignore this
            local_state = STATE_OK

        # * High total RSS: This instance has a memory fragmentation and RSS overhead greater than
        #   1.4 (this means that the Resident Set Size of the Valkey process is much larger than the
        #   sum of the logical allocations Valkey performed). This problem is usually due either to
        #   a large peak memory (check if there is a peak memory entry above in the report) or may
        #   result from a workload that causes the allocator to fragment memory a lot. If the
        #   problem is a large peak memory, then there is no issue. Otherwise, make sure you are
        #   using the Jemalloc allocator and not the default libc malloc. Note: The currently used
        #   allocator is \"%s\".\n\n", ZMALLOC_LIB);
        if ' * High total RSS: ' in result['memory_doctor']:
            if ' * Peak memory: ' in result['memory_doctor']:
                # if the problem is a large peak memory, then there is no issue
                local_state = STATE_OK
            if 'The currently used allocator is "jemalloc' in result['memory_doctor']:
                # otherwise, make sure you are using the Jemalloc allocator and not the default libc
                # malloc
                local_state = STATE_OK

        # * High allocator fragmentation: This instance has an allocator external fragmentation
        #   greater than 1.1. This problem is usually due either to a large peak memory (check if
        #   there is a peak memory entry above in the report) or may result from a workload that
        #   causes the allocator to fragment memory a lot. You can try enabling 'activedefrag'
        #   config option.\n\n");

        # * High allocator RSS overhead: This instance has an RSS memory overhead is greater than
        #   1.1 (this means that the Resident Set Size of the allocator is much larger than the sum
        #   what the allocator actually holds). This problem is usually due to a large peak memory
        #   (check if there is a peak memory entry above in the report), you can try the MEMORY
        #   PURGE command to reclaim it.\n\n");

        # * High process RSS overhead: This instance has non-allocator RSS memory overhead is
        #   greater than 1.1 (this means that the Resident Set Size of the Valkey process is much
        #   larger than the RSS the allocator holds). This problem may be due to Lua scripts or
        #   Modules.\n\n");

        # * Big replica buffers: The replica output buffers in this instance are greater than 10MB
        #   for each replica (on average). This likely means that there is some replica instance
        #   that is struggling receiving data, either because it is too slow or because of
        #   networking issues. As a result, data piles on the master output buffers. Please try to
        #   identify what replica is not receiving data correctly and why. You can use the INFO
        #   output in order to check the replicas delays and the CLIENT LIST command to check the
        #   output buffers of each replica.\n\n");

        # * Big client buffers: The clients output buffers in this instance are greater than 200K
        #   per client (on average). This may result from different causes, like Pub/Sub clients
        #   subscribed to channels bot not receiving data fast enough, so that data piles on the
        #   Valkey instance output buffer, or clients sending commands with large replies or very
        #   large sequences of commands in the same pipeline. Please use the CLIENT LIST command in
        #   order to investigate the issue if it causes problems in your instance, or to understand
        #   better why certain clients are using a big amount of memory.\n\n");

        # * Many scripts: There seem to be many cached scripts in this instance (more than 1000).
        #   This may be because scripts are generated and `EVAL`ed, instead of being parameterized
        #   (with KEYS and ARGV), `SCRIPT LOAD`ed and `EVALSHA`ed. Unless `SCRIPT FLUSH` is called
        #   periodically, the scripts' caches may end up consuming most of your memory.\n\n");

        msg = f'{msg[:-2]}. '
        msg += (
            f'{result["memory_doctor"]}{lib.base.state2str(local_state, prefix=" ")}, '
        )
        state = lib.base.get_worst(state, local_state)

    # effective memory ceiling: maxmemory if set, otherwise total system memory
    mem_ceiling = int(float(result['maxmemory'])) or int(
        float(result['total_system_memory'])
    )

    # prepare perfdata
    perfdata += lib.base.get_perfdata(
        'mem_usage',
        mem_usage,
        uom='%',
        warn=args.WARN,
        crit=args.CRIT,
        _min=0,
        _max=100,
    )
    perfdata += lib.base.get_perfdata(
        'keyspace_hit_rate',
        keyspace_hit_rate,
        uom='%',
        warn=None,
        crit=None,
        _min=0,
        _max=100,
    )

    perfdata += lib.base.get_perfdata(
        'clients_blocked_clients',
        result['blocked_clients'],
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'clients_connected_clients',
        result['connected_clients'],
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    perfdata += lib.base.get_perfdata(
        'memory_maxmemory',
        result['maxmemory'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'memory_mem_fragmentation_ratio',
        result['mem_fragmentation_ratio'],
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'memory_used_memory',
        result['used_memory'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=mem_ceiling,
    )
    perfdata += lib.base.get_perfdata(
        'memory_used_memory_lua',
        result['used_memory_lua'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'memory_used_memory_rss',
        result['used_memory_rss'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    perfdata += lib.base.get_perfdata(
        'persistence_aof_current_rewrite_time_sec',
        # -1 while none is running
        max(0, result['aof_current_rewrite_time_sec']),
        uom='s',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'persistence_rdb_changes_since_last_save',
        result['rdb_changes_since_last_save'],
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'persistence_rdb_current_bgsave_time_sec',
        # -1 while none is running
        max(0, result['rdb_current_bgsave_time_sec']),
        uom='s',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    perfdata += lib.base.get_perfdata(
        'replication_connected_slaves',
        result['connected_slaves'],
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'replication_repl_backlog_histlen',
        result['repl_backlog_histlen'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'replication_repl_backlog_size',
        result['repl_backlog_size'],
        uom='B',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    perfdata += lib.base.get_perfdata(
        'server_uptime_in_seconds',
        result['uptime_in_seconds'],
        uom='s',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    perfdata += lib.base.get_perfdata(
        'stats_latest_fork_usec',
        result['latest_fork_usec'],
        uom='us',
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )

    if rates is not None:
        bytes_rates = ('total_net_input_bytes', 'total_net_output_bytes')
        for key in stats_counters:
            perfdata += lib.base.get_perfdata(
                f'stats_{key}_per_second',
                round(rates[key], 1),
                uom='B' if key in bytes_rates else None,
                warn=None,
                crit=None,
                _min=0,
                _max=None,
            )
        # microseconds-of-CPU per wall-second / 1e6 * 100 = CPU percent
        for key in cpu_counters:
            perfdata += lib.base.get_perfdata(
                f'cpu_{key}_percent',
                round(rates[key] / 10000, 1),
                uom='%',
                warn=None,
                crit=None,
                _min=0,
                _max=None,
            )

    # with --verbose, the INFO answer follows the result instead of
    # preceding it, one line per field and one blank line between its sections.
    # INFO ends its lines with `\r\n`, and a carriage return left in the output shows
    # up as an empty line in some web interfaces.
    msg = msg[:-2]
    if args.VERBOSE:
        for line in info.splitlines():
            line = line.strip()
            if line.startswith('#'):
                msg += '\n'
            if line:
                msg += f'\n{line}'

    # over and out
    lib.base.oao(
        msg,
        state,
        perfdata,
        always_ok=args.ALWAYS_OK,
        no_perfdata=args.NO_PERFDATA,
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
