3proxy/tests/cases/logs.py
Vladimir Dubrovin 2052dc0a4f Logs: a thread and a queue for each log; build-time defaults
Once 3proxy runs (after the first configuration is in force, so after a
daemon's fork), each logger has a thread of its own writing from a
queue, and a client never waits for a log: ODBC, RADIUS, a full disk or
a pipe nobody reads hold up only that log's thread.

A queued line is fixed size: the part of struct clientparam logging
reads (everything before the filters: addresses, counters, names and
credentials, which hold no pointer it follows but srv), the time it was
logged, and its text, 256 bytes inline or a copy of its own if longer.
The thread formats it: struct tm is per call now, timechanged() and
dologname() included, and the type's format() takes the time.

minlogqueue (64) slots are allocated as the thread starts and kept,
maxlogqueue (4096) lines are queued at most. A line which finds the
queue full is dropped and counted on the last one queued; once that is
written, "Log X has its queue full, N records dropped" goes to where
errors go. A log which fails is no longer written to the error log
instead: its lines are dropped and counted while it is retried every
minute, and the count is told when it is open again, or when it was
reopened after failed writes.

A service which ended is stopped at once (srvstop(): sockets, listener,
its clients gone), but freed (srvrelease()) through its log's queue,
behind the lines which refer to it and to its configuration: the event
is part of struct srvparam, so a full queue never drops it. logfinish()
writes what is queued, 10 seconds at most, also as 3proxy exits on Unix.
Standalone services write as before, as each line comes.

LOGQUEUEMIN, LOGQUEUEMAX, DEFAULT_MAXCHILD, DEFAULT_POOLMAX,
DEFAULT_POOLTIMEOUT and POOLPERSERVICE (the automatic thread pool cap
for each service, which was 32 in the code) can be set with -D at build
time.

Tests: a log nobody reads (a FIFO) holds no client up, drops past its
queue and says so, and lines queued for a service a reload removed are
written as it formatted them; /dev/full says what it dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:41:48 +03:00

217 lines
8.3 KiB
Python

"""Logs: one for each log string, shared by every service naming it, kept
over a reload while something uses it and closed once nothing does. A log
which cannot be written passes its lines to standard error."""
import os
import time
def _read(path):
try:
with open(path, "rb") as fp:
return fp.read().decode("utf-8", "replace")
except OSError:
return ""
def _proxied(path, mark):
"""Whether a proxy, not the origin, logged the request marked so."""
return any(line.startswith("LOG PROXY") and mark in line
for line in _read(path).splitlines())
def _wait(cond, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
if cond():
return True
time.sleep(0.05)
return cond()
def _opened(pid, path):
"""How many times the process has the file open, None without /proc."""
fds = f"/proc/{pid}/fd"
if not os.path.isdir(fds):
return None
n = 0
for fd in os.listdir(fds):
try:
if os.readlink(os.path.join(fds, fd)) == path:
n += 1
except OSError:
pass
return n
def _stalled(t):
"""A log nobody reads: a FIFO whose pipe is full. Clients are not held
up, the lines past the queue are dropped and counted, and lines queued
for a service a reload removed are still written as it formatted them."""
fifo = os.path.join(t.tmpdir, "logs-fifo")
os.mkfifo(fifo)
# opened for reading before 3proxy opens it for writing, then not read
rfd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
origin, port, adm = t.free_port(), t.free_port(), t.free_port()
# lines of 2 KB, made so by the service's format: the pipe's 64 KB fill
# before the requests are done
pad = "z" * 2000
def conf(proxyline):
return f"""
log
maxlogqueue 8
auth iponly
allow *
http data * /data
httpsrv -p{origin}
flush
admin -p{adm}
flush
logformat "LQ {pad} %T"
auth iponly
allow *
{proxyline}
"""
server = t.start("logs_stall", conf(f'proxy -p{port} -l{fifo}'),
ports=[origin, port, adm])
began = time.time()
ok = 0
for i in range(120):
r = t.http(f"http://127.0.0.1:{origin}/data?size=10&n{i:04d}",
proxy=f"127.0.0.1:{port}")
ok += r.status == 200
t.eq(120, ok, "a log nobody reads holds no client up")
t.eq(True, time.time() - began < 60, "and they are not slowed to its pace")
# the service goes: its lines still queued are written as it made them
t.write_config("logs_stall", conf("flush"))
t.http(f"http://127.0.0.1:{adm}/R")
got = b""
deadline = time.time() + 15
while time.time() < deadline and "has its queue full" not in server.output():
try:
chunk = os.read(rfd, 65536)
except BlockingIOError:
chunk = b""
got += chunk
if not chunk:
time.sleep(0.05)
t.eq(True, "has its queue full" in server.output(),
"the lines past its queue are dropped, and said so")
# what is left in the pipe and the queue, read to the end
deadline = time.time() + 5
while time.time() < deadline:
try:
chunk = os.read(rfd, 65536)
except BlockingIOError:
chunk = b""
if chunk:
got += chunk
deadline = time.time() + 1
else:
time.sleep(0.05)
os.close(rfd)
lines = got.decode("utf-8", "replace").splitlines()
t.eq(True, 0 < len(lines) < 120, "some lines are written, not all")
t.eq(True, all(l.startswith("Q " + pad) for l in lines),
"each as its service formats it, the service gone or not")
def config(origin, p1, p2, p3, adm, main, shared=None):
"""p2 and p3 log to shared with -l, if it is given, or are not there."""
lines = [f"log {main}", 'logformat "LLOG %N %T"', "auth iponly", "allow *",
"http data * /data", f"httpsrv -p{origin}", f"proxy -p{p1}"]
if shared:
lines += [f"proxy -p{p2} -l{shared}", f"proxy -p{p3} -l{shared}"]
lines.append(f"admin -p{adm}")
return "\n".join(lines) + "\n"
def run(t):
origin, p1, p2, p3, adm = (t.free_port() for _ in range(5))
first = os.path.join(t.tmpdir, "logs-first.log")
second = os.path.join(t.tmpdir, "logs-second.log")
shared = os.path.join(t.tmpdir, "logs-shared.log")
server = t.start("logs", config(origin, p1, p2, p3, adm, first, shared),
ports=[origin, p1, p2, p3, adm])
pid = server.proc.pid
def get(port, mark):
return t.http(f"http://127.0.0.1:{origin}/data?size=10&{mark}",
proxy=f"127.0.0.1:{port}").status
# --- the log command and -l ----------------------------------------
t.eq(200, get(p1, "one"), "a request through the first proxy")
t.eq(200, get(p2, "two"), "one through the second")
t.eq(200, get(p3, "three"), "one through the third")
t.eq(True, _wait(lambda: _proxied(first, "one")), "the log command's file gets its line")
t.eq(True, _wait(lambda: _proxied(shared, "two") and _proxied(shared, "three")),
"the two services with the same -l write to the same file")
t.eq(False, _proxied(first, "two"), "and not to the log command's")
t.eq(False, _proxied(shared, "one"), "nor the other way round")
n = _opened(pid, shared)
if n is not None:
t.eq(1, n, "the file named by both is opened once")
t.eq(1, _opened(pid, first), "the log command's file is open")
def reload(main, until):
t.write_config("logs", config(origin, p1, p2, p3, adm, main))
t.http(f"http://127.0.0.1:{adm}/R")
return _wait(until)
# --- a reload to another file: the old one is closed once unused -----
t.eq(True, reload(second, lambda: "Accepting connections" in _read(second)),
"the new configuration logs to the new file")
t.eq(200, get(p1, "four"), "a request after the reload")
t.eq(True, _wait(lambda: _proxied(second, "four")), "goes to the new file")
t.eq(False, _proxied(first, "four"), "not to the old one")
if n is not None:
t.eq(True, _wait(lambda: _opened(pid, first) == 0),
"the old file is closed once its services are gone")
t.eq(True, _wait(lambda: _opened(pid, shared) == 0),
"and so is the file of -l nobody names any more")
t.eq(1, _opened(pid, second), "the new one is open")
# --- the same file, rotated daily: reopened under the day's name -----
dated = second + "." + time.strftime("%Y.%m.%d")
t.eq(True, reload(second + " D", lambda: os.path.exists(dated)),
"a rotation given to the log reopens it under the dated name")
t.eq(200, get(p1, "five"), "a request after that")
t.eq(True, _wait(lambda: _proxied(dated, "five")), "goes to the dated file")
t.eq(False, _proxied(second, "five"), "not to the undated one")
# --- a log which fails: its lines dropped, and said so -----------------
if os.path.exists("/dev/full"):
o2, q = t.free_port(), t.free_port()
full = t.start("logs_full", f"""
log /dev/full
logformat "LLOG %N %T"
auth iponly
allow *
http data * /data
httpsrv -p{o2}
proxy -p{q}
""", ports=[o2, q])
# a buffer's worth, at least, before writing it fails
for i in range(80):
t.http(f"http://127.0.0.1:{o2}/data?size=10&{'x' * 40}{i}",
proxy=f"127.0.0.1:{q}")
t.eq(True, _wait(lambda: "Log /dev/full could not be written" in full.output()),
"a log a full device refuses says so on standard error")
t.eq(True, "records dropped" in full.output(), "with the records it dropped")
t.eq(False, "LOG PROXY" in full.output(), "its lines are not written elsewhere")
# --- a log which stalls: clients go on, the queue is bounded ----------
if hasattr(os, "mkfifo"):
_stalled(t)
# --- a log which cannot be opened is an error in the configuration ---
out = t.run_config("logs_bad", f"""
log {os.path.join(t.tmpdir, "no", "such", "dir", "x.log")}
proxy -p{t.free_port()}
""")
t.contains(out, "x.log", "a log which cannot be opened is named")