Skip to content

feat: time-series download analytics - #2134

Merged
netomi merged 1 commit into
mainfrom
split/download-analytics
Sep 13, 2026
Merged

netomi merged 1 commit into
mainfrom
split/download-analytics

Conversation

@netomi

@netomi netomi commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Stack: 5 of 6. Split out of #2027. Base: #2133 (split/timescale-datasource).

Makes the analytics live: the Timescale repository behind the seam the ingestion rework left inert, a query service, and a public endpoint.

  • TimescaleDownloadAnalyticsRepository implements DownloadAnalyticsRepository against the time-series DSLContext, batching writes and reading the download_stats_daily aggregate
  • the interface regains findSeries, held back from refactor: rework the download ingestion pipeline #2132 because it drags in the query model (DownloadSeriesRequest, DownloadSeriesRow and their enums)
  • DownloadAnalyticsService applies a settling margin (ovsx.analytics.settling-margin, 2h) so a series never ends on a bucket the continuous aggregate has not caught up to
  • DownloadAnalyticsAPI exposes the series, publicly cacheable
  • /api/version reports analyticsEnabled, so a client can tell whether the endpoints exist before calling them

All gated on ovsx.analytics.enabled. With it off no bean is created and captureDownload's ObjectProvider stays unsatisfied, exactly as in the base.

The thing to decide in this PR

This is where the request-path capture stops being inert, and it deserves a decision rather than a nod.

StorageUtilService.increaseDownloadCount is @Transactional and called synchronously on the download path (LocalRegistryService:303, before the file response is returned). captureDownload sits inside it.

The transactional half is already handled, and deliberately: b84a70cd3 documents and enforces that writes run on the time-series pool and cannot join a caller's registry transaction — one save() is one transaction of its own. So an analytics write is never rolled back with the registry's work, and never extends its atomicity.

What remains is latency:

  • it is a synchronous JDBC round-trip on the download request thread, so every download not covered by a log source pays a Timescale insert before its file response
  • the call sits inside @Transactional, so it holds the registry's own JPA connection for that duration
  • the 2-second timeout from feat: give download analytics its own time-series database #2133 bounds pool acquisition, not statement execution — a time-series database that is slow rather than unreachable adds its latency directly, with no ceiling

This is the registry's hottest endpoint. The failure is handled (exceptions are swallowed so a download never fails for analytics), but slowness is not the same as failure. Whether that is acceptable, or whether the capture should be handed to an executor or dropped in favour of log-based ingestion alone, is a judgement for the maintainers — I have deliberately not changed the design.

It is also the one part of #2027 that is genuinely hard to see from the outside, which is most of why the stack put it here rather than in the ingestion PR.

Verification

Full server suite: 1216 tests passing (1179 from the base plus 37 here).

Refs #2027, #2025

@netomi
netomi force-pushed the split/download-analytics branch from 3304111 to 4f71069 Compare September 2, 2026 16:09
@netomi
netomi force-pushed the split/download-analytics branch from 4762381 to 7629c23 Compare September 3, 2026 14:46
Base automatically changed from split/timescale-datasource to main September 13, 2026 11:35
@netomi
netomi force-pushed the split/download-analytics branch from 7629c23 to 0c754b1 Compare September 13, 2026 11:46
Makes the analytics live: the Timescale repository behind the seam the ingestion
rework left inert, a query service, and a public endpoint for a download series.

DownloadAnalyticsRepository stops being an interface and becomes the Timescale
implementation itself: it batches writes to the download_event hypertable and reads
the download_stats_daily aggregate through the time-series DSLContext. It regains
findSeries, which was held back from the ingestion rework because it drags in the
query model.

The interface and its single implementation are one class rather than two. The
interface existed so the ingestion rework could ship the analytics seam present but
inert, which it has now done; with the implementation landing there is exactly one
production implementation and nothing to choose between. Keeping them apart bought
only the two test doubles, and those subclass the concrete type just as well - the
inherited DSLContext is never touched because they override everything their caller
calls. What it cost was an abstraction in repositories importing the query model
from analytics while analytics imported the abstraction back.

DownloadAnalyticsService applies a settling margin (ovsx.analytics.settling-margin,
2 hours) so a series never ends on a bucket the continuous aggregate has not
caught up to yet. DownloadAnalyticsAPI exposes the series, publicly cacheable.

/api/version reports analyticsEnabled, so a client can tell whether the endpoints
are there before calling them.

Everything is gated on ovsx.analytics.enabled. With it off, no bean is created
and captureDownload's ObjectProvider stays unsatisfied, exactly as before.

Co-Authored-By: gnugomez <gomezbanaco@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netomi
netomi force-pushed the split/download-analytics branch from 0c754b1 to 17bcf15 Compare September 13, 2026 12:02
@netomi
netomi merged commit 6acc209 into main Sep 13, 2026
5 checks passed
@netomi
netomi deleted the split/download-analytics branch September 13, 2026 12:05
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.

1 participant