Skip to content

fix(timerange): honour bound markers and return client time strings as sent - #24

Open
Akshay-Narayan-Pai wants to merge 21 commits into
mainfrom
fix/timerange-normalisation
Open

Akshay-Narayan-Pai wants to merge 21 commits into
mainfrom
fix/timerange-normalisation

Conversation

@Akshay-Narayan-Pai

@Akshay-Narayan-Pai Akshay-Narayan-Pai commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #25

Why

Registering an instantaneous segment at time 0 ([0:0]) returns 500:

metastore: InsertSegments: insert seg 0: ERROR: new row for relation "segments"
violates check constraint "segments_upper_ns_positive" (SQLSTATE 23514)

metastore.nsRange ignored the bound markers. [0:0] became the empty range (0, 0), and every closed range lost its last nanosecond, so the store accepted some overlapping segments. TAMS says: "Service implementations MUST take inclusivity markers of the timerange into account."

Tracing the defect showed that the design rules themselves were missing or wrong. This PR corrects the specification first (ADRs and design docs), then implements it test-first.

Decisions

ADR Decides
0038 (accepted) Parse the TAMS format (App Note 0008). Follow BBC mediatimestamp where the note is silent, and the spec text where the two disagree. The sign applies to the whole value (-1:500000000 = −1.5 s). One-sided ranges, bare timestamps, and empty ranges parse. An instant is a whole nanosecond, so (0:0_0:1) is empty (rule 10).
0039 (accepted, supersedes 0014) Store client time strings exactly as sent. timerange.NsBounds is the only conversion to half-open nanosecond bounds, with checked arithmetic. Bounds are for comparison only. One named predicate per query: overlap for GET/HEAD, containment for DELETE. ListFlows matches a flow when one of its segments overlaps.
0040 (accepted, supersedes 0015) A segment timerange must be bounded, non-empty, and start-inclusive, or the segment is a per-segment invalid-timerange failure in a 200. upper_ns NOT NULL, CHECK (upper_ns > lower_ns). A constraint violation is a server defect: 500, idempotency key released.

What changed

internal/timerange

  • New parser for the full format, including every form the schema regex accepts.
  • NsBounds() with ErrOutOfRange and ErrEmptyRange.
  • TimestampFromDuration.
  • String() is canonical and is used only for derived values. It renders [ts] for an instant and never writes -0:0.

Metastore and migration

  • 000005 is edited in place: upper_ns is NOT NULL, and segments_bounds_nonempty replaces segments_upper_ns_positive.
  • nsRange is deleted. Insert, overlap, list, delete, and ListFlows all use NsBounds.
  • The store keeps the four client strings (domain.Segment.*Raw). These are internal fields, and the API does not change.
  • The flow timerange and X-Paging-Timerange are built from the parsed stored strings.
  • DELETE removes only the segments that the query covers completely.
  • An empty query range matches nothing. For ListFlows, it returns only flows with no segments.

Service, conversion, and handlers

  • Per-segment invalid-timerange failures.
  • Responses return time strings byte for byte.
  • A query timerange that does not parse, or does not fit in int64, returns 400 invalid-timerange. This covers GET, HEAD, and DELETE segments, GET and HEAD /flows, and GET /flows/{flowId}.
  • HEAD /flows shares the GET filter.
  • durationToTAI is replaced by TimestampFromDuration.

CI

  • The lint workflow fails on a nanosecond literal (1_000_000_000, 1000000000, 1e9) in server code outside internal/timerange.

Docs

  • Design docs for internal-timerange, internal-metastore, internal-service-segment, internal-service-flow, and internal-httpx-conversion.
  • conformance.md.

Tests

Every edge case from the analysis has a test. Each test was seen failing before its fix. Highlights:

  • [0:0] end to end.
  • Negative values inside one second.
  • Mixed-bracket neighbours, within one batch and across batches.
  • The spec example [1:0_2:0) then [2:0].
  • DELETE containment.
  • ListFlows gap, empty, and _.
  • Raw string round trip.
  • Both int64 edges.
  • The migration constraints (23514 and 23502).
  • A released idempotency key after 500.

A property test checks 50,000 random pairs and shows that Overlaps always agrees with the bounds comparison. It found the whole-nanosecond case that became ADR-0038 rule 10.

Verified locally:

  • go build, go vet, and golangci-lint (0 issues).
  • go test -short -race ./....
  • All integration tests (-tags=integration -race, testcontainers).

Upgrading a v0.1.0-alpha.0 database

golang-migrate does not re-run the edited 000005. Follow ADR-0040 rule 7 by hand:

  1. Recompute the bounds.
  2. Remove empty and open-ended rows.
  3. Apply the schema change.

Rows written before this PR hold '0:0' for an absent ts_offset, and now read back as an explicit "0:0".

Out of scope (follow-ups)

  • HEAD segments on an unknown flow returns 500, where GET returns 404. The same applies to an invalid cursor. This predates this PR.
  • Internally, treat an absent ts_offset as 0:0, as the spec says. No logic reads the value today.

Registering an instantaneous segment at time 0 ([0:0]) returns 500,
because metastore.nsRange ignores the bound markers: [0:0] becomes the
empty range (0, 0) and fails the segments_upper_ns_positive check. The
same defect drops the last nanosecond of every closed range, so some
overlapping segments are accepted. TAMS says "Service implementations
MUST take clusivity markers of the timerange into account."

Tracing the defect showed that several design rules were missing or
wrong, so this change corrects the specification before the code:

- ADR-0038: parse timestamps and timeranges by the TAMS grammar (App
  Note 0008), and follow BBC's mediatimestamp library where the note is
  silent. The sign applies to the whole value, so -1:500000000 is
  -1.5 s. One-sided ranges, bare timestamps, and empty ranges parse.
- ADR-0039 (supersedes ADR-0014 on acceptance): store each client time
  string exactly as sent, derive half-open nanosecond bounds only in
  timerange.NsBounds with checked arithmetic, use the bounds only for
  comparison, and name one predicate per query. DELETE uses containment,
  as TAMS requires.
- ADR-0040 (supersedes ADR-0015 on acceptance): a segment timerange must
  be bounded, non-empty, and start-inclusive, reported as a per-segment
  failure. upper_ns becomes NOT NULL and the check becomes
  upper_ns > lower_ns. Migration 000005 is edited in place, and
  v0.1.0-alpha.0 databases are fixed by hand.

The design documents for internal-timerange, internal-metastore,
internal-service-segment, internal-service-flow, and
internal-httpx-conversion follow the ADRs. Every changed rule is marked
"(pending)" until the implementation lands. conformance.md records the
segment timerange policy and the DELETE defect, and replaces a
"Time-range syntax" section that described behaviour the parser does not
have.
@Akshay-Narayan-Pai
Akshay-Narayan-Pai requested a review from a team as a code owner September 28, 2026 14:33
@Akshay-Narayan-Pai Akshay-Narayan-Pai changed the title docs(timerange): specify timerange normalisation and segment invariants fix(timerange): specify timerange normalisation and segment invariants Sep 28, 2026
@Akshay-Narayan-Pai Akshay-Narayan-Pai changed the title fix(timerange): specify timerange normalisation and segment invariants docs(timerange): specify timerange normalisation and segment invariants Sep 28, 2026
@Akshay-Narayan-Pai Akshay-Narayan-Pai changed the title docs(timerange): specify timerange normalisation and segment invariants fix(timerange): specify timerange normalisation and segment invariants Sep 28, 2026
Rewrites the text added in the previous commit to the simple-English
(ASD-STE100, pragmatic) rules:

- Active voice where the agent is known: the parser, the store, the
  service, or the handler.
- No "would", "now", "therefore", "reach", or "perform" outside quoted
  specification text.
- No semicolons, and no "below" or "above" as references. Links name
  the target section.
- Sentences within 25 words, lists with a lead-in colon, and no nested
  list.

Also fixes a structural error from the previous commit: the BR-SEG-02
insertion split the existing paragraph, so the sentence about
client-supplied GetURLs had moved below the new timerange rules. It is
back in its original paragraph.

Verbatim TAMS quotes are unchanged. British spelling stays, to match
the rest of the repository.
TAMS never calls the timestamp and timerange string definitions a
grammar. The schemas and the API document say "format", and App Note
0008 titles its section "TimeRange representation".

Renames ADR-0038 to 0038-tams-timestamp-and-timerange-format.md and
updates its title, the ADR index, every link to it, BR-TR-06, BR-TR-13,
and the conformance.md section heading, which becomes "Time-range
format". No document linked to the old heading anchor.
- BR-META-21: give explicit SQL for all three ListFlows cases: no filter
  for "_", NOT EXISTS for an empty range, and EXISTS with overlap for a
  non-empty range. Record that OpenTAMS reads "Flows that overlap" as
  "a segment of the flow overlaps", and that HEAD /flows follows GET,
  although the upstream HEAD text omits the empty-range sentence.
- BR-META-10 and the metastore domain types: state how the raw client
  strings travel through domain.Segment.*Raw from conversion to the
  store and back. Remove the stale Segment struct copy. FailedSegment
  and SegmentPage point at domain.Segment.
- BR-TR-07: define the Overlaps/NsBounds property for every pair of
  ranges, including empty and unbounded ones.
- BR-TR-10: give the exact CI command and its scope (server code under
  internal/, pkg/, cmd/). Use git grep -w, because git grep -E does not
  support \b. State the exact int64 nanosecond limits.
- BR-TR-12: write "-" only for a negative value, so String() never
  emits "-0:0".
- BR-TR-14 (new): TimestampFromDuration replaces durationToTAI in
  handlers/root.go, because TAMS types min_object_timeout as a
  Timestamp.
- ADR-0038 rule 9 (new) and BR-TR-05, BR-CONV-09: the schema regex
  accepts "(10:0)", but the schema text forbids exclusive markers on an
  instantaneous range. OpenTAMS follows the text, not mediatimestamp.
- conformance.md: exact representable range, the TAMS source of the
  empty-timerange rule for GET /flows, the ListFlows interpretation,
  and HEAD /flows.
The block in the metastore business rules had drifted from the code:

- ListSegmentsParams does not exist. ListSegments takes ListQuery.
- ListSourcesParams was missing Label, Format, TagExists, and TagValues.
- ListFlowsParams was missing FrameWidth and FrameHeight, and named the
  type timerange.Timerange instead of timerange.TimeRange.
- FailedSegment and SegmentPage belong to internal/domain, and both
  copies had wrong or missing fields.
- InsertBatch, InsertResult, RejectReason, ListQuery, DeleteQuery, and
  DeleteResult were missing.

The block now copies only the types that internal/metastore owns, each
checked against flows.go and segments.go, and links to internal/domain
for the segment types. Source, Flow, CollectionItem, SourcePage, and
FlowPage already matched and are unchanged.
Lower is inclusive and Upper is exclusive. The field comments now say so,
and the type comment names the half-open interval, so a reader does not
take Upper as the last instant.
Implement ADR-0038 in internal/timerange:

- The sign applies to the whole timestamp value, stored floor-normalised.
  -1:500000000 is -1.5 s, and -0:x is valid.
- One-sided ranges, bare timestamps, omitted markers, and the other
  regex-valid forms parse as mediatimestamp parses them.
- End before start parses as an empty range, not as an error.
- An instant is a whole nanosecond (new ADR-0038 rule 10), so
  (0:0_0:1) is empty, and Overlaps is defined through Intersect.
- String renders [ts] for an instant, () for every empty range, and
  never writes -0:0.
- NsBounds converts a range to half-open int64 bounds with checked
  arithmetic, ErrOutOfRange, and ErrEmptyRange.
- TimestampFromDuration replaces ad hoc duration arithmetic.

Docs: BR-TR-04 to BR-TR-07 and conformance record the regex-only forms
and the whole-nanosecond rule.
Add TimerangeRaw, TSOffsetRaw, ObjectTimerangeRaw, and LastDurationRaw,
so conversion, service, and metastore can return the exact strings a
client sent (BR-CONV-08, BR-META-10).
A segment timerange must be bounded, non-empty, start-inclusive, and fit
in int64 nanoseconds (ADR-0040 rules 1-2, BR-SEG-02). A segment that
breaks a rule is an invalid-timerange failure in Failed and never reaches
the store; the other segments in the batch continue.

A List or Delete query timerange outside the int64 nanosecond range is
invalid-timerange (BR-SEG-09); an empty query range goes to the store.
The failure log carries the client's timerange string.
SegmentFromAPI and the POST decoder keep the exact wire string of
timerange, ts_offset, object_timerange, and last_duration in the domain
*Raw fields next to each parsed value. SegmentToAPI and
RegisterFailureToAPI write the raw strings and never String() of a parsed
value, so a client that sends [10:0] or -0:0 reads back the same bytes
(BR-CONV-08).

A string that does not parse, such as (10:0), stays a request-level
schema-validation error; an empty range parses and passes through to the
service (BR-CONV-09).
Remove durationToTAI, which did its own nanosecond arithmetic and
rendered a negative duration as -1:-500000000. GetService now uses
timerange.TimestampFromDuration(d).String() (BR-TR-14).
…merange

GET and HEAD /flows returned every flow when the timerange did not parse;
they now return 400 invalid-timerange. HEAD /flows uses the GET parameter
parser, so both apply the same filter, including an empty range
(ADR-0039 rule 7, BR-CONV-09).

GET, HEAD, and DELETE /flows/{flowId}/segments map the service's
invalid-timerange error for an out-of-range query to 400 (BR-SEG-09).

A store error the handler does not map, such as a constraint violation,
stays a 500 with the idempotency key released (ADR-0040 rule 3,
BR-IDMP-02); tests now pin that a retry with the same key is processed.
Add the BR-TR-10 step: it fails if git grep finds 1_000_000_000,
1000000000, or 1e9 in non-test server code outside internal/timerange.
git grep status 1 (no match) passes; a status above 1 fails as an error.
Replace segments_upper_ns_positive, which rejected valid negative ends and
accepted empty ranges, with upper_ns NOT NULL and
segments_bounds_nonempty CHECK (upper_ns > lower_ns) (ADR-0040 rule 4).
Edited in place; the down migration reverts both.
Remove nsRange, which ignored bound markers, so [0:0] became (0, 0) and
failed the CHECK constraint with a 500. Every conversion now goes through
timerange.NsBounds (ADR-0039, BR-META-10, BR-META-21):

- insert and both overlap checks compare one set of half-open bounds
- an empty or out-of-range segment timerange fails the batch
- the four time columns hold the client strings, read back as *Raw
- ListSegments uses overlap, DELETE uses containment
- ListFlows matches a flow when one of its segments overlaps; an empty
  query returns flows with no segments; _ applies no condition
- the flow and page timeranges are joined from parsed stored strings and
  rendered with TimeRange.String(), not built from nanoseconds
BR-META-21: the store returns timerange.ErrOutOfRange for a query
timerange that does not fit in int64 nanoseconds, and the caller maps it
to 400 invalid-timerange. GET and HEAD /flows returned 500.

Also rename the loader's allowed-survivor entry to the new
segments_bounds_nonempty constraint.
The handler ignored a timerange that did not parse and returned the flow
unfiltered. It now returns 400 invalid-timerange, like every other
timerange query (BR-CONV-09, SCN-HTTP-08). The spec declares no 400 for
this endpoint, so the handler returns an AppError and the error
middleware writes the problem.
InsertSegments rejects a segment without TimerangeRaw (BR-META-10). The
concurrency integration tests and the scaletest bench now set it.

Docs: BR-META-10 records how absent optional strings are stored and the
TimerangeRaw requirement. BR-CONV-09 and conformance record 400
invalid-timerange for every timerange query parameter, including GET
/flows/{flowId}, which the upstream spec declares no 400 for.
The code for the three ADRs has landed. Set them to accepted, set
ADR-0014 and ADR-0015 to superseded, remove the (pending) markers, and
remove the notes that described the defects this branch fixes.
@Akshay-Narayan-Pai Akshay-Narayan-Pai changed the title fix(timerange): specify timerange normalisation and segment invariants fix(timerange): honour bound markers and return client time strings as sent Sep 30, 2026
// such as a database constraint violation, which the service's
// validation makes unreachable from client input (ADR-0040 rule 3).
// A 5xx is never cached; the key is released (BR-IDMP-02).
log.Error("RegisterBatch failed; releasing idempotency key", zap.Error(err))

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants