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