This is an automated email from the ASF dual-hosted git repository.

bcall pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new b7db7b8190 Test: add msdms milestone logging field validation autest 
(#12899)
b7db7b8190 is described below

commit b7db7b8190ad90ae11a2200bebc025270c2a3736
Author: Bryan Call <[email protected]>
AuthorDate: Mon Mar 23 14:07:23 2026 -0700

    Test: add msdms milestone logging field validation autest (#12899)
    
    Add end-to-end autest for msdms milestone timing log fields.
    
    * New test sends a cacheable request twice (miss then hit) and
      validates all Phase 1 msdms fields in the log output.
    * Reusable verify_milestone_fields.py checks: all 16 fields
      present, values are integers with no epoch-length garbage,
      miss-path chain sums to c_ttfb within 2ms tolerance, and
      hit-path fields (hit_proc, hit_xfer) are non-negative.
    * Allow up to -10ms jitter on the dns field to handle
      millisecond-granularity overlap between SERVER_FIRST_CONNECT
      and CACHE_OPEN_READ_END.
---
 .../logging/log-milestone-fields.test.py           | 173 +++++++++++++++++
 .../gold_tests/logging/verify_milestone_fields.py  | 211 +++++++++++++++++++++
 2 files changed, 384 insertions(+)

diff --git a/tests/gold_tests/logging/log-milestone-fields.test.py 
b/tests/gold_tests/logging/log-milestone-fields.test.py
new file mode 100644
index 0000000000..60fc69f453
--- /dev/null
+++ b/tests/gold_tests/logging/log-milestone-fields.test.py
@@ -0,0 +1,173 @@
+'''
+Verify that msdms milestone difference fields produce valid output for both
+cache-miss and cache-hit transactions.  This exercises the Phase 1 timing
+fields proposed for the squid.log local_disk format.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+
+Test.Summary = 'Verify msdms milestone logging fields for cache miss and cache 
hit paths'
+Test.ContinueOnFail = True
+
+
+class MilestoneFieldsTest:
+    """
+    Sends two requests for the same cacheable URL: the first is a cache miss
+    that populates the cache, the second is a cache hit served from RAM cache.
+    A custom log format records all Phase 1 milestone timing fields plus the
+    cache result code.  A validation script then parses the log and checks:
+
+    - Every expected key=value pair is present on each line
+    - All values are integers (>= 0 or -1 for unset milestones)
+    - Cache miss line: ms > 0, origin-phase fields present
+    - Cache hit line: hit_proc >= 0 and hit_xfer >= 0
+    - No epoch-length garbage values (> 1_000_000_000)
+    """
+
+    # All Phase 1 msdms fields plus ms and cache result code for 
identification.
+    LOG_FORMAT = (
+        'crc=%<crc> ms=%<ttms>'
+        ' c_ttfb=%<{TS_MILESTONE_UA_BEGIN_WRITE-TS_MILESTONE_SM_START}msdms>'
+        ' 
c_tls=%<{TS_MILESTONE_TLS_HANDSHAKE_END-TS_MILESTONE_TLS_HANDSHAKE_START}msdms>'
+        ' 
c_hdr=%<{TS_MILESTONE_UA_READ_HEADER_DONE-TS_MILESTONE_SM_START}msdms>'
+        ' 
c_proc=%<{TS_MILESTONE_CACHE_OPEN_READ_BEGIN-TS_MILESTONE_UA_READ_HEADER_DONE}msdms>'
+        ' 
cache=%<{TS_MILESTONE_CACHE_OPEN_READ_END-TS_MILESTONE_CACHE_OPEN_READ_BEGIN}msdms>'
+        ' 
dns=%<{TS_MILESTONE_SERVER_FIRST_CONNECT-TS_MILESTONE_CACHE_OPEN_READ_END}msdms>'
+        ' 
o_tcp=%<{TS_MILESTONE_SERVER_CONNECT_END-TS_MILESTONE_SERVER_FIRST_CONNECT}msdms>'
+        ' 
o_wait=%<{TS_MILESTONE_SERVER_FIRST_READ-TS_MILESTONE_SERVER_CONNECT_END}msdms>'
+        ' 
o_hdr=%<{TS_MILESTONE_SERVER_READ_HEADER_DONE-TS_MILESTONE_SERVER_FIRST_READ}msdms>'
+        ' 
o_proc=%<{TS_MILESTONE_UA_BEGIN_WRITE-TS_MILESTONE_SERVER_READ_HEADER_DONE}msdms>'
+        ' 
o_body=%<{TS_MILESTONE_SERVER_CLOSE-TS_MILESTONE_UA_BEGIN_WRITE}msdms>'
+        ' c_xfer=%<{TS_MILESTONE_SM_FINISH-TS_MILESTONE_SERVER_CLOSE}msdms>'
+        ' 
hit_proc=%<{TS_MILESTONE_UA_BEGIN_WRITE-TS_MILESTONE_CACHE_OPEN_READ_END}msdms>'
+        ' 
hit_xfer=%<{TS_MILESTONE_SM_FINISH-TS_MILESTONE_UA_BEGIN_WRITE}msdms>')
+
+    def __init__(self):
+        self._server = Test.MakeOriginServer("server")
+        self._nameserver = Test.MakeDNServer("dns", default='127.0.0.1')
+        self._setupOriginServer()
+        self._setupTS()
+
+    def _setupOriginServer(self):
+        self._server.addResponse(
+            "sessionlog.json",
+            {
+                'timestamp': 100,
+                "headers": "GET /cacheable HTTP/1.1\r\nHost: 
example.com\r\n\r\n",
+                "body": "",
+            },
+            {
+                'timestamp': 100,
+                "headers":
+                    (
+                        "HTTP/1.1 200 OK\r\n"
+                        "Content-Type: text/plain\r\n"
+                        "Cache-Control: max-age=300\r\n"
+                        "Connection: close\r\n"
+                        "\r\n"),
+                "body": "This is a cacheable response body for milestone 
testing.",
+            },
+        )
+
+    def _setupTS(self):
+        self._ts = Test.MakeATSProcess("ts", enable_cache=True)
+
+        self._ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http|log',
+                'proxy.config.dns.nameservers': 
f'127.0.0.1:{self._nameserver.Variables.Port}',
+                'proxy.config.dns.resolv_conf': 'NULL',
+                'proxy.config.http.cache.http': 1,
+                'proxy.config.log.max_secs_per_buffer': 1,
+            })
+
+        self._ts.Disk.remap_config.AddLine(f'map / 
http://127.0.0.1:{self._server.Variables.Port}/')
+
+        self._ts.Disk.logging_yaml.AddLines(
+            f'''
+logging:
+  formats:
+    - name: milestone_test
+      format: '{self.LOG_FORMAT}'
+  logs:
+    - filename: milestone_fields
+      format: milestone_test
+      mode: ascii
+'''.split("\n"))
+
+    @property
+    def _log_path(self) -> str:
+        return os.path.join(self._ts.Variables.LOGDIR, 'milestone_fields.log')
+
+    @property
+    def _validate_script(self) -> str:
+        return os.path.join(Test.TestDirectory, 'verify_milestone_fields.py')
+
+    def run(self):
+        self._sendCacheMiss()
+        self._waitForCacheIO()
+        self._sendCacheHit()
+        self._waitForLog()
+        self._validateLog()
+
+    def _sendCacheMiss(self):
+        tr = Test.AddTestRun('Cache miss request')
+        tr.Processes.Default.StartBefore(self._server)
+        tr.Processes.Default.StartBefore(self._nameserver)
+        tr.Processes.Default.StartBefore(self._ts)
+        tr.MakeCurlCommand(
+            f'--verbose --header "Host: example.com" '
+            f'http://127.0.0.1:{self._ts.Variables.port}/cacheable', 
ts=self._ts)
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self._server
+        tr.StillRunningAfter = self._ts
+
+    def _waitForCacheIO(self):
+        tr = Test.AddTestRun('Wait for cache write to complete')
+        tr.Processes.Default.Command = 'sleep 1'
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self._server
+        tr.StillRunningAfter = self._ts
+
+    def _sendCacheHit(self):
+        tr = Test.AddTestRun('Cache hit request')
+        tr.MakeCurlCommand(
+            f'--verbose --header "Host: example.com" '
+            f'http://127.0.0.1:{self._ts.Variables.port}/cacheable', 
ts=self._ts)
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self._server
+        tr.StillRunningAfter = self._ts
+
+    def _waitForLog(self):
+        tr = Test.AddTestRun('Wait for log file to be written')
+        tr.Processes.Default.Command = 
(os.path.join(Test.Variables.AtsTestToolsDir, 'condwait') + f' 60 1 -f 
{self._log_path}')
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self._server
+        tr.StillRunningAfter = self._ts
+
+    def _validateLog(self):
+        tr = Test.AddTestRun('Validate milestone fields in log')
+        tr.Processes.Default.Command = f'python3 {self._validate_script} 
{self._log_path}'
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.TimeOut = 10
+        tr.Processes.Default.Streams.stdout += 
Testers.ContainsExpression('PASS', 'Validation script should report PASS')
+        tr.Processes.Default.Streams.stdout += 
Testers.ExcludesExpression('FAIL', 'Validation script should not report FAIL')
+
+
+MilestoneFieldsTest().run()
diff --git a/tests/gold_tests/logging/verify_milestone_fields.py 
b/tests/gold_tests/logging/verify_milestone_fields.py
new file mode 100644
index 0000000000..dd161cfccd
--- /dev/null
+++ b/tests/gold_tests/logging/verify_milestone_fields.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+'''
+Validate milestone timing fields in an ATS log file.
+
+Parses key=value log lines and checks:
+  - All expected fields are present
+  - All values are integers
+  - No epoch-length garbage (> 1 billion) from the difference_msec bug
+  - Cache miss lines have ms > 0 and origin-phase fields populated
+  - Cache hit lines have hit_proc and hit_xfer populated
+  - The miss-path chain sums to approximately c_ttfb
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import sys
+
+ALL_FIELDS = [
+    'crc',
+    'ms',
+    'c_ttfb',
+    'c_tls',
+    'c_hdr',
+    'c_proc',
+    'cache',
+    'dns',
+    'o_tcp',
+    'o_wait',
+    'o_hdr',
+    'o_proc',
+    'o_body',
+    'c_xfer',
+    'hit_proc',
+    'hit_xfer',
+]
+
+TIMING_FIELDS = [f for f in ALL_FIELDS if f != 'crc']
+
+# Fields that form the contiguous miss-path chain to c_ttfb:
+#   c_ttfb = c_hdr + c_proc + cache + dns + o_conn + o_wait + o_hdr + o_proc
+MISS_CHAIN = ['c_hdr', 'c_proc', 'cache', 'dns', 'o_tcp', 'o_wait', 'o_hdr', 
'o_proc']
+
+EPOCH_THRESHOLD = 1_000_000_000
+
+
+def parse_line(line: str) -> dict[str, str]:
+    """Parse a space-separated key=value log line into a dict."""
+    fields = {}
+    for token in line.strip().split():
+        if '=' in token:
+            key, val = token.split('=', 1)
+            fields[key] = val
+    return fields
+
+
+def validate_line(fields: dict[str, str], line_num: int) -> list[str]:
+    """Return a list of error strings (empty = pass)."""
+    errors = []
+
+    for name in ALL_FIELDS:
+        if name not in fields:
+            errors.append(f'line {line_num}: missing field "{name}"')
+
+    for name in TIMING_FIELDS:
+        val_str = fields.get(name)
+        if val_str is None:
+            continue
+        # Accept '-' as a valid sentinel for unset milestones.
+        if val_str == '-':
+            continue
+        try:
+            val = int(val_str)
+        except ValueError:
+            errors.append(f'line {line_num}: field "{name}" is not an integer: 
{val_str!r}')
+            continue
+
+        if val > EPOCH_THRESHOLD:
+            errors.append(f'line {line_num}: field "{name}" looks like an 
epoch leak: {val} '
+                          f'(> {EPOCH_THRESHOLD})')
+
+    crc = fields.get('crc', '')
+    is_miss = 'MISS' in crc or 'NONE' in crc
+    is_hit = 'HIT' in crc and 'MISS' not in crc
+
+    ms_str = fields.get('ms', '0')
+    try:
+        ms_val = int(ms_str)
+    except ValueError:
+        ms_val = -1
+
+    if ms_val < 0 and ms_str != '-':
+        errors.append(f'line {line_num}: ms should be >= 0, got {ms_val}')
+
+    if is_miss:
+        for name in MISS_CHAIN:
+            val_str = fields.get(name)
+            if val_str is None or val_str == '-':
+                continue
+            try:
+                val = int(val_str)
+            except ValueError:
+                continue
+            if val < -10:
+                errors.append(f'line {line_num}: miss field "{name}" has 
unexpected value: {val}')
+
+        # Verify chain sum approximates c_ttfb (within tolerance for rounding).
+        chain_vals = []
+        for name in MISS_CHAIN:
+            val_str = fields.get(name)
+            if val_str is None or val_str == '-':
+                chain_vals.append(0)
+                continue
+            try:
+                v = int(val_str)
+                chain_vals.append(v if v >= 0 else 0)
+            except ValueError:
+                chain_vals.append(0)
+
+        chain_sum = sum(chain_vals)
+        c_ttfb_str = fields.get('c_ttfb')
+        if c_ttfb_str and c_ttfb_str != '-':
+            try:
+                c_ttfb_val = int(c_ttfb_str)
+                # Allow 2ms tolerance for rounding across multiple 
sub-millisecond fields.
+                if c_ttfb_val >= 0 and abs(chain_sum - c_ttfb_val) > 2:
+                    errors.append(
+                        f'line {line_num}: chain sum ({chain_sum}) != c_ttfb 
({c_ttfb_val}), '
+                        f'diff={abs(chain_sum - c_ttfb_val)}ms')
+            except ValueError:
+                pass
+
+    if is_hit:
+        for name in ['hit_proc', 'hit_xfer']:
+            val_str = fields.get(name)
+            if val_str is None:
+                errors.append(f'line {line_num}: cache hit missing field 
"{name}"')
+                continue
+            if val_str == '-':
+                errors.append(f'line {line_num}: cache hit field "{name}" 
should not be "-"')
+                continue
+            try:
+                val = int(val_str)
+                if val < 0:
+                    errors.append(f'line {line_num}: cache hit field "{name}" 
should be >= 0, got {val}')
+            except ValueError:
+                errors.append(f'line {line_num}: cache hit field "{name}" is 
not an integer: {val_str!r}')
+
+    return errors
+
+
+def main():
+    if len(sys.argv) != 2:
+        print(f'Usage: {sys.argv[0]} <log_file>', file=sys.stderr)
+        sys.exit(1)
+
+    log_path = sys.argv[1]
+    try:
+        with open(log_path) as f:
+            lines = [l for l in f.readlines() if l.strip()]
+    except FileNotFoundError:
+        print(f'FAIL: log file not found: {log_path}')
+        sys.exit(1)
+
+    if len(lines) < 2:
+        print(f'FAIL: expected at least 2 log lines (miss + hit), got 
{len(lines)}')
+        sys.exit(1)
+
+    all_errors = []
+    miss_found = False
+    hit_found = False
+
+    for i, line in enumerate(lines, start=1):
+        fields = parse_line(line)
+        crc = fields.get('crc', '')
+        if 'MISS' in crc:
+            miss_found = True
+        if 'HIT' in crc and 'MISS' not in crc:
+            hit_found = True
+        errors = validate_line(fields, i)
+        all_errors.extend(errors)
+
+    if not miss_found:
+        all_errors.append('No cache miss line found in log')
+    if not hit_found:
+        all_errors.append('No cache hit line found in log')
+
+    if all_errors:
+        for err in all_errors:
+            print(f'FAIL: {err}')
+        sys.exit(1)
+    else:
+        print(f'PASS: validated {len(lines)} log lines '
+              f'(miss={miss_found}, hit={hit_found}), all fields correct')
+        sys.exit(0)
+
+
+if __name__ == '__main__':
+    main()

Reply via email to