diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1a6f0fea9..61eb52be4 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -150,10 +150,13 @@ jobs:
build-prod:
<<: *defaults
+ # 16 GB leaves room for build workers alongside the 8 GB Node heap.
+ resource_class: xlarge
environment:
DEPLOY_ENV: "PROD"
LOGICAL_ENV: "prod"
APPNAME: "platform-ui-mvp"
+ NODE_OPTIONS: "--max-old-space-size=8192"
steps: *build_steps
# Just tests commited code.
diff --git a/.environments/.env.prod b/.environments/.env.prod
index 0c3a4bab5..6106936f2 100644
--- a/.environments/.env.prod
+++ b/.environments/.env.prod
@@ -1,9 +1,9 @@
REACT_APP_HOST_ENV=prod
# First-party AWS Clickstream analytics
-REACT_APP_ANALYTICS_API_URL=
-REACT_APP_AWS_ANALYTICS_APP_ID=
-REACT_APP_AWS_ANALYTICS_ENDPOINT=
+REACT_APP_ANALYTICS_API_URL=https://api.topcoder.com/v1/analytics
+REACT_APP_AWS_ANALYTICS_APP_ID=topcoder_web
+REACT_APP_AWS_ANALYTICS_ENDPOINT=https://events.topcoder.com/collect
REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false
diff --git a/README.md b/README.md
index 3f6631aa2..713fa2205 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,19 @@ NODE_OPTIONS=--max-old-space-size=8192 LOGICAL_ENV=dev yarn build
The `master` branch is auto-deployed to the production environment: https://platform-ui.topcoder.com.
+The production build job uses CircleCI's `xlarge` Docker resource class (16 GB RAM)
+and `NODE_OPTIONS=--max-old-space-size=8192` to allow an 8 GB Node heap. The optimized
+build can exceed Node's default heap limit; the remaining container memory is
+reserved for build workers and other process overhead. Keep the container memory
+and heap limit sized together when changing these settings in `.circleci/config.yml`.
+
+To run the same production build locally on a machine with sufficient memory:
+
+```bash
+nvm use
+NODE_OPTIONS=--max-old-space-size=8192 LOGICAL_ENV=prod yarn build
+```
+
## Pull Requests
If a Jira ticket requires any code changes, it should have its own pull request.
diff --git a/infrastructure/analytics-api/README.md b/infrastructure/analytics-api/README.md
index 84a246a55..ec5831092 100644
--- a/infrastructure/analytics-api/README.md
+++ b/infrastructure/analytics-api/README.md
@@ -30,13 +30,18 @@ once within the same deadline. Clients can opt into resumable requests with
returns `202`, `Retry-After`, and a server-generated query token. The browser
polls with that token until the original statement completes instead of
starting another warehouse query. Tokens are accepted only for the exact SQL,
-validated parameters, retry attempt, and current or previous four-hour window.
-The four-hour window remains below the Data API's eight-hour idempotency
-retention and permits one complete boundary-crossing window without making
-tokens reusable for other reports. An EventBridge schedule invokes the default
-Campaigns report, filter-option query, and `/opportunities` route report at each
-new window, allowing their statements to finish before an interactive request
-while preserving Redshift Serverless idle cost controls. Browser polling is
+validated parameters, retry attempt, and the current or preceding fifteen
+one-minute windows. New requests reuse a completed Data API statement for at
+most one minute; a pending request keeps its original token while polling, so
+slow warehouse startup does not restart work at each minute boundary. The
+in-process report and filter caches also expire after 60 seconds. Consequently,
+loaded data appears in a newly requested report within approximately two minutes,
+plus query execution time. Processing runs hourly in both dev and production;
+collection continues between batches.
+
+The existing four-hour EventBridge schedule still prepares the default Campaigns
+report, filter-option query, and `/opportunities` route report. Interactive requests
+refresh independently of that schedule. Browser polling is
bounded. Concurrency and API throttles cap warehouse pressure, and successful
responses use `Cache-Control: private, no-store`. Logs contain request IDs and
service-owned error categories only.
diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py
index c4f612589..9c4be9c83 100644
--- a/infrastructure/analytics-api/src/handler.py
+++ b/infrastructure/analytics-api/src/handler.py
@@ -23,11 +23,12 @@
MAX_RESULT_ROWS = 2_000
MAX_QUERY_ATTEMPTS = 2
REPORT_CACHE_SECONDS = 60
-FILTER_CACHE_SECONDS = 300
-# Redshift Data API retains idempotency tokens for eight hours. Four-hour
-# windows leave a complete prior window available for safe boundary-crossing
-# polls while allowing scheduled requests to prepare the default reports.
-QUERY_TOKEN_WINDOW_SECONDS = 14_400
+FILTER_CACHE_SECONDS = 60
+# Reuse completed statements for at most one minute. Pending queries keep
+# their original token across fifteen previous windows, so slow warehouse
+# startup does not restart work when the freshness window advances.
+QUERY_TOKEN_WINDOW_SECONDS = 60
+QUERY_TOKEN_RESUME_WINDOWS = 15
QUERY_POLL_DELAY_MILLISECONDS = 1_000
SAFE_FILTER_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{1,100}$")
QUERY_TOKEN_PATTERN = re.compile(r"^[0-9a-f]{64}$")
@@ -1509,10 +1510,10 @@ def _query_client_token(
sql: Server-owned SQL template.
parameters: Validated named parameters.
attempt: Provider retry index; failed statements receive a new token.
- token_window: Optional explicit four-hour bucket used to validate a resume token.
+ token_window: Optional explicit one-minute bucket used to validate a resume token.
Returns:
- Sixty-four-character SHA-256 token stable within a four-hour window.
+ Sixty-four-character SHA-256 token stable within a one-minute window.
Raises:
Does not raise for validated handler inputs and configured environment values.
@@ -1543,14 +1544,14 @@ def _query_token_attempt(
query_token: SHA-256 token returned by a pending response.
Returns:
- Provider attempt encoded into the valid current or prior-window token.
+ Provider attempt encoded into a current or recent one-minute token.
Raises:
ValueError when the token belongs to another query, filter set, or expired window.
"""
current_window = int(time.time() // QUERY_TOKEN_WINDOW_SECONDS)
- for token_window in (current_window, current_window - 1):
+ for token_window in range(current_window, current_window - QUERY_TOKEN_RESUME_WINDOWS - 1, -1):
for attempt in range(MAX_QUERY_ATTEMPTS):
expected = _query_client_token(sql, parameters, attempt, token_window)
if query_token == expected:
diff --git a/infrastructure/analytics-api/template.yaml b/infrastructure/analytics-api/template.yaml
index f376e579d..ad32763d0 100644
--- a/infrastructure/analytics-api/template.yaml
+++ b/infrastructure/analytics-api/template.yaml
@@ -150,7 +150,7 @@ Resources:
AnalyticsPrewarmRule:
Type: AWS::Events::Rule
Properties:
- Description: Prepares default analytics results once per four-hour Data API token window.
+ Description: Prepares default analytics results every four hours; interactive requests refresh independently.
ScheduleExpression: cron(0 0/4 * * ? *)
State: ENABLED
Targets:
diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py
index 2c0d8ff83..daf45b78b 100644
--- a/infrastructure/analytics-api/tests/test_handler.py
+++ b/infrastructure/analytics-api/tests/test_handler.py
@@ -421,6 +421,53 @@ def test_timeout_token_resumes_across_an_idempotency_window_boundary(self) -> No
)
cancel.assert_not_called()
+ def test_pending_query_resumes_after_multiple_freshness_windows(self) -> None:
+ """Warehouse startup can span minutes without creating another statement."""
+
+ with patch.object(self.module.time, "time", return_value=6_000):
+ token = self.module._query_client_token("SELECT 1", [], 0)
+ with (
+ patch.object(self.module.time, "time", return_value=6_600),
+ patch.object(self.module, "_query_wait_seconds", return_value=0),
+ patch.object(
+ self.module._redshift_data,
+ "execute_statement",
+ return_value={"Id": "original-query"},
+ ) as execute,
+ self.assertRaises(self.module.QueryTimeout),
+ ):
+ self.module._execute_query("SELECT 1", [], None, token)
+ self.assertEqual(token, execute.call_args.kwargs["ClientToken"])
+
+ def test_expired_resume_token_does_not_reach_redshift(self) -> None:
+ """Polling older than the fifteen-minute allowance fails before provider access."""
+
+ with patch.object(self.module.time, "time", return_value=6_000):
+ token = self.module._query_client_token("SELECT 1", [], 0)
+ with (
+ patch.object(self.module.time, "time", return_value=6_960),
+ patch.object(self.module._redshift_data, "execute_statement") as execute,
+ self.assertRaisesRegex(ValueError, "invalid or expired"),
+ ):
+ self.module._execute_query("SELECT 1", [], None, token)
+ execute.assert_not_called()
+
+ def test_new_report_request_refreshes_after_one_minute(self) -> None:
+ """A completed report is queried again after a minute instead of four hours."""
+
+ with (
+ patch.object(self.module._redshift_data, "execute_statement", return_value={"Id": "result"}) as execute,
+ patch.object(self.module._redshift_data, "describe_statement", return_value={"Status": "FINISHED"}),
+ patch.object(self.module, "_statement_rows", side_effect=[[{"count": 1}], [{"count": 20}]]),
+ ):
+ with patch.object(self.module.time, "time", return_value=6_000):
+ first = self.module._execute_query("SELECT COUNT(*) FROM events", [], None)
+ with patch.object(self.module.time, "time", return_value=6_060):
+ second = self.module._execute_query("SELECT COUNT(*) FROM events", [], None)
+ self.assertEqual([{"count": 1}], first)
+ self.assertEqual([{"count": 20}], second)
+ self.assertNotEqual(execute.call_args_list[0].kwargs["ClientToken"], execute.call_args_list[1].kwargs["ClientToken"])
+
def test_resume_token_cannot_be_reused_for_another_query(self) -> None:
"""A server token is bound to the fixed SQL and validated parameter set."""
diff --git a/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx b/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx
index 9b0e05187..2ebf0697a 100644
--- a/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx
+++ b/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx
@@ -143,7 +143,7 @@ const ScorecardHeader: FC = () => {
)}
- {isAdmin && workflowRun.usage?.cacheCreationInputTokens && (
+ {isAdmin && !!workflowRun.usage?.cacheCreationInputTokens && (
@@ -153,7 +153,7 @@ const ScorecardHeader: FC = () => {
)}
- {isAdmin && workflowRun.usage?.cacheCreationInputTokens && (
+ {isAdmin && !!workflowRun.usage?.cacheCreationInputTokens && (
diff --git a/src/apps/work/README.md b/src/apps/work/README.md
index 9f5d6db16..3ef987143 100644
--- a/src/apps/work/README.md
+++ b/src/apps/work/README.md
@@ -51,6 +51,15 @@ default and white on a teal hover background.
- `WorkAppContextProvider`: authentication token and derived role/access flags
- `SWRConfigProvider`: shared SWR fetch configuration for the app
+## Project asset uploads
+
+The project asset file picker uploads to `submission-staging-prod` when
+`REACT_APP_HOST_ENV=prod` and `submission-staging-dev` in other environments.
+`REACT_APP_FILE_PICKER_SUBMISSION_CONTAINER_NAME` (or the legacy
+`FILE_PICKER_SUBMISSION_CONTAINER_NAME`) overrides this default. The same bucket
+is sent as `s3Bucket` to the Projects API when saving the attachment, so it can
+transfer the uploaded file into project attachment storage.
+
## Extending The App
1. Add feature routes under `work-app.routes.tsx` children.
diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts
index 3ce09d62a..4260beecb 100644
--- a/src/apps/work/src/lib/constants.ts
+++ b/src/apps/work/src/lib/constants.ts
@@ -281,7 +281,7 @@ export const UPDATE_SKILLS_V5_API_URL = process.env.REACT_APP_UPDATE_SKILLS_V5_A
export const FILE_PICKER_SUBMISSION_CONTAINER_NAME = process.env.REACT_APP_FILE_PICKER_SUBMISSION_CONTAINER_NAME
|| process.env.FILE_PICKER_SUBMISSION_CONTAINER_NAME
- || 'submission-staging-dev'
+ || (EnvironmentConfig.ENV === 'prod' ? 'submission-staging-prod' : 'submission-staging-dev')
export const PROJECT_ATTACHMENTS_FOLDER = process.env.REACT_APP_PROJECT_ATTACHMENTS_FOLDER
|| process.env.PROJECT_ATTACHMENTS_FOLDER