65: Persist Evelyn status-index history per named Evelyn instance
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-08
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
See `proposal.md` for motivation and the capability delta for the behavioral contract. Issue #64 established that Evelyn owns sampling and complete history while EMC is presentation-only. Ticker already demonstrates durable append with missing-final-newline handling, while configuration parsing demonstrates strict first-actual-entry format declarations. Issue #65 applies those repository patterns to a distinct Evelyn-owned format without changing either existing subsystem.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make a normalized instance name a safe permanent storage identity with atomic JVM-local ownership.
|
||||||
|
- Make startup transactional from the caller's perspective: validate and parse fully before publishing history or starting a thread.
|
||||||
|
- Coordinate persistence and publication under Evelyn ownership with durable append ordering.
|
||||||
|
- Separate restartable stop from irreversible close without releasing a live writer's identity.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Creating or repairing runtime storage, cross-process locking, migration, retention, rotation, compaction, additional record types, test creation, or changes to Ticker, Raydium, EMC file access, and unrelated composition.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Use AutoCloseable for permanent lifecycle completion
|
||||||
|
|
||||||
|
`Evelyn` extends `AutoCloseable` and overrides `close()` without a checked exception. `start()` and `stop()` remain the operational lifecycle: stop is restartable and retains the name, while close is permanent and idempotent. This is preferable to an Evelyn-specific disposal name because Java callers can use standard resource ownership and the semantic distinction remains explicit in the contract.
|
||||||
|
|
||||||
|
### Validate completely before atomic reservation
|
||||||
|
|
||||||
|
Construction first validates every non-name argument and derives the NFC name, case-folded registry key, direct-child directory, and status path. Only after all fallible argument validation succeeds does a static concurrent set reserve the key atomically. The constructor performs no I/O. This prevents invalid construction from leaking reservations and keeps independently constructible components stopped.
|
||||||
|
|
||||||
|
### Treat close as release-after-confirmed-termination
|
||||||
|
|
||||||
|
Lifecycle state distinguishes stopped, started, stopping, closed, and termination-failed conditions. Stop interrupts and awaits the owned scheduler. Only confirmed termination returns the object to restartable stopped state. Close invokes the same safe stop path and removes the registry key only after confirmation; any failure is reported while the object retains both its non-startable safety state and reservation. Repeated successful close is a no-op.
|
||||||
|
|
||||||
|
### Make startup loading transactional
|
||||||
|
|
||||||
|
Every start rechecks the directory and file using both metadata/access predicates and actual UTF-8 reading. All lines are read, the dedicated status-format declaration is validated before record parsing, and records are decoded into a temporary list with non-decreasing timestamp checks. Only a successful complete load replaces the in-memory list and establishes the last persisted timestamp. The executor is created last. Failed startup leaves memory empty, storage untouched, and no sampling thread.
|
||||||
|
|
||||||
|
### Keep status format validation dedicated
|
||||||
|
|
||||||
|
The loader hardcodes `SUPPORTED_STATUS_HISTORY_FORMAT_VERSION = 1` and applies the same declaration convention as configuration files, but produces status-history-specific diagnostics and does not couple version numbers or parsing code to configuration or Ticker formats. Type codes are explicit constants rather than enum ordinals. Full original lines are retained for line diagnostics before comment removal.
|
||||||
|
|
||||||
|
### Serialize durable append and publication
|
||||||
|
|
||||||
|
The single sampling thread still coordinates the critical operation explicitly. Under the history lock it rejects timestamps earlier than the last persisted timestamp, opens the existing file without create/truncate options, adds a newline first when necessary, writes the encoded UTF-8 record, and forces the channel. Only then does it update the last timestamp and append to visible memory. I/O failures escape this critical operation into the sampling boundary, which logs instance/path/context and leaves scheduling alive.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Filesystem access predicates can race with actual operations] → perform the required predicates for useful diagnostics and still treat actual read/open/write/force failures as authoritative.
|
||||||
|
- [A blocked sampling task may ignore interruption] → never release the name unless executor termination is confirmed; retain the reservation and report failure.
|
||||||
|
- [JVM-local uniqueness cannot protect another process] → document cross-process locking as out of scope and rely on deployment ownership.
|
||||||
|
- [Full history loading is unbounded] → accept the issue-defined complete-load behavior; retention and compaction are future work.
|
||||||
|
- [NFC plus case folding can map spellings to one identity] → preserve the winning normalized display spelling on disk while reserving a `Locale.ROOT` lower-case key.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Before enabling each instance, operators create `data/evelyn/Production/status.log` and `data/evelyn/Test/status.log` with physical first line `FORMAT_VERSION=1`. Deployment then starts Ticker, constructs and starts both named Evelyn instances, and opens EMC. Rollback requires stopping/closing the instances but leaves status files untouched; older code will ignore them because it has no Evelyn persistence support.
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Evelyn currently discards every collected status-index point when it stops, so process restarts lose operational history. Each independently named Evelyn environment needs durable, operator-provisioned append-only storage while Evelyn—not Mission Control—continues to own collection, validation, and publication.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING** Require every `EvelynImpl` to have a validated, NFC-normalized instance name that selects its permanent `data/evelyn/<name>/status.log` identity.
|
||||||
|
- Reserve normalized instance names atomically and case-insensitively within the JVM until permanent, idempotent close.
|
||||||
|
- **BREAKING** Extend Evelyn lifecycle with permanent close in addition to restartable start/stop.
|
||||||
|
- Strictly validate externally provisioned storage, format version 1, every record, and non-decreasing timestamps before publishing restored history or starting sampling.
|
||||||
|
- Durably append each sampled type-1 status record before publishing it in memory, while isolating individual append failures.
|
||||||
|
- Keep Production and Test storage, lifecycle, and history independent and keep EMC presentation-only.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `evelyn-status-index-history`: Replace process-local disposable history with named-instance storage, strict restore validation, persist-before-publish behavior, and distinct restartable stop versus permanent close semantics.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The change affects the public Evelyn lifecycle, `EvelynImpl` construction and persistence, and temporary Production/Test composition in `NenjimHubImpl`. Operators must provision each instance directory and versioned `status.log` before startup. EMC, AssetAZ Ticker, Raydium PriceSource, generated Detag sources, and unrelated services remain behaviorally unchanged.
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Evelyn instances have safe unique persistent names
|
||||||
|
Every Evelyn instance SHALL have an NFC-normalized Unicode name whose preserved spelling and capitalization selects the direct child directory `data/evelyn/<name>`. A name SHALL be rejected if it is empty or whitespace-only, has leading or trailing whitespace, is `.` or `..`, ends with a period, contains a control character or any of `< > : " / \ | ? *`, is a case-insensitive Windows-reserved filename including a reserved base with an extension, or normalizes as a path to anything other than one direct child of `data/evelyn`. Within one JVM, normalized names SHALL be reserved atomically and compared case-insensitively using locale-independent rules.
|
||||||
|
|
||||||
|
#### Scenario: A portable Unicode name is accepted
|
||||||
|
- **WHEN** an instance is constructed with a valid Unicode name containing Danish characters
|
||||||
|
- **THEN** NFC normalization is applied and the normalized spelling and capitalization identify its direct child data directory
|
||||||
|
|
||||||
|
#### Scenario: An unsafe name is rejected without reservation
|
||||||
|
- **WHEN** construction receives an invalid name or any other invalid constructor argument
|
||||||
|
- **THEN** construction fails before reserving a persistent identity
|
||||||
|
|
||||||
|
#### Scenario: Concurrent equivalent names conflict
|
||||||
|
- **WHEN** two constructor calls in one JVM concurrently request names that are equal after NFC normalization and locale-independent case folding
|
||||||
|
- **THEN** exactly one reserves the persistent identity and the other fails
|
||||||
|
|
||||||
|
### Requirement: Evelyn distinguishes restartable stop from permanent close
|
||||||
|
Stopping Evelyn SHALL terminate sampling, clear memory, preserve its persistent file, and retain its name reservation so the same object can restart. Evelyn SHALL support permanent, idempotent close; closing an active instance SHALL first stop it safely, a successfully closed instance SHALL never start again, and its name SHALL be released only after its sampling thread has definitely terminated. A termination failure SHALL be reported and SHALL retain the reservation.
|
||||||
|
|
||||||
|
#### Scenario: A stopped instance restarts
|
||||||
|
- **WHEN** a stopped but not closed Evelyn instance is started again
|
||||||
|
- **THEN** it retains exclusive ownership of the same name and reloads the complete persisted history before sampling
|
||||||
|
|
||||||
|
#### Scenario: An active instance closes successfully
|
||||||
|
- **WHEN** close is called on an active instance and its sampling thread terminates
|
||||||
|
- **THEN** memory is cleared, the name reservation is released, and later start calls fail
|
||||||
|
|
||||||
|
#### Scenario: Close cannot confirm termination
|
||||||
|
- **WHEN** close cannot confirm that the sampling thread terminated
|
||||||
|
- **THEN** close reports failure and retains the name reservation
|
||||||
|
|
||||||
|
#### Scenario: Close is repeated
|
||||||
|
- **WHEN** close is called after the instance was successfully closed
|
||||||
|
- **THEN** it completes without changing state or failing
|
||||||
|
|
||||||
|
### Requirement: Evelyn uses externally provisioned per-instance storage
|
||||||
|
Each named instance SHALL use the existing regular file `data/evelyn/<normalized-name>/status.log`. Before every start, Evelyn SHALL require the direct instance path to be an existing readable and writable directory and `status.log` to be an existing readable and writable regular file. Evelyn SHALL NOT create, replace, repair, truncate, rename, or otherwise provision either path. Any validation or actual open failure SHALL identify the affected path, leave memory unpublished, and prevent sampling from starting.
|
||||||
|
|
||||||
|
#### Scenario: Required storage is absent or unsuitable
|
||||||
|
- **WHEN** the instance directory or status file is missing, the path has the wrong type, or required access is unavailable
|
||||||
|
- **THEN** start fails with the affected path and reason without modifying storage, publishing history, or starting sampling
|
||||||
|
|
||||||
|
#### Scenario: Storage changes while stopped
|
||||||
|
- **WHEN** storage becomes invalid after stop and the same instance is started again
|
||||||
|
- **THEN** the complete startup validation is repeated and restart fails without modifying storage
|
||||||
|
|
||||||
|
### Requirement: Evelyn validates status history format strictly
|
||||||
|
The UTF-8 `status.log` format SHALL have supported version `1`, declared by `FORMAT_VERSION=1` as the first actual entry; blank lines and full-line comments MAY precede it when loading, while an externally provisioned new file SHALL place it on the physical first line. Missing, duplicate, malformed, misplaced, or unsupported declarations SHALL fail loading before record parsing. After the declaration, blank lines and comments SHALL be ignored, inline comments MAY follow data, and every other entry SHALL be a valid record with an explicit stable numeric type code.
|
||||||
|
|
||||||
|
Type `1` SHALL encode `<UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Evelyn Price Index>`. It SHALL have exactly three fields. Unknown types, invalid timestamps or decimals, missing or surplus fields, and decreasing timestamps SHALL fail the complete load; equal timestamps SHALL be accepted. A valid declaration with no records SHALL represent empty history. Applicable failures SHALL identify the file path, one-based line number, and original line, and Evelyn SHALL neither skip, sort, partially publish, nor repair invalid content.
|
||||||
|
|
||||||
|
#### Scenario: A valid versioned history loads
|
||||||
|
- **WHEN** the file has a valid first actual version declaration and valid type-1 records in non-decreasing timestamp order
|
||||||
|
- **THEN** Evelyn restores every record in file order with zero values for the five unimplemented indexes
|
||||||
|
|
||||||
|
#### Scenario: The format declaration is invalid
|
||||||
|
- **WHEN** the declaration is missing, duplicated, malformed, misplaced, or not version 1
|
||||||
|
- **THEN** startup fails before parsing records and reports useful file diagnostics
|
||||||
|
|
||||||
|
#### Scenario: A record is invalid
|
||||||
|
- **WHEN** a record has an unknown type, malformed field, wrong field count, or timestamp earlier than its predecessor
|
||||||
|
- **THEN** startup fails with path, line number, and original line without exposing partial history or altering the file
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Evelyn samples without overlapping or catch-up attempts
|
||||||
|
Each started Evelyn environment SHALL own its sampling executor, attempt its first sample only after complete storage validation and history restoration, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the Evelyn lifecycle independently of whether Mission Control is open. Evelyn SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup or persistence operation without adding a fake or unpersisted point, terminating Evelyn, or cancelling the next attempt. Stopping Evelyn SHALL terminate its sampling executor.
|
||||||
|
|
||||||
|
#### Scenario: Initial Ticker data is unavailable
|
||||||
|
- **WHEN** the immediate sampling attempt cannot obtain a latest EVE/USDT price
|
||||||
|
- **THEN** no point is persisted or published, diagnostic context is logged, and the next fixed-delay attempt remains scheduled
|
||||||
|
|
||||||
|
#### Scenario: A later sample succeeds
|
||||||
|
- **WHEN** a later attempt obtains a latest EVE/USDT price whose millisecond timestamp is not earlier than the last persisted timestamp
|
||||||
|
- **THEN** exactly one type-1 record is durably appended before the corresponding point becomes visible in memory
|
||||||
|
|
||||||
|
#### Scenario: Persistence fails for one sample
|
||||||
|
- **WHEN** appending or durably flushing a calculated point fails
|
||||||
|
- **THEN** the point is not published, instance and path context are logged, and the next fixed-delay attempt remains scheduled
|
||||||
|
|
||||||
|
#### Scenario: A sampled timestamp goes backwards
|
||||||
|
- **WHEN** a newly sampled timestamp is earlier than the last persisted timestamp
|
||||||
|
- **THEN** no record or point is added, the failure is logged, and later attempts remain scheduled
|
||||||
|
|
||||||
|
### Requirement: Evelyn provides historical status-index measurements
|
||||||
|
Each named Evelyn instance SHALL publish a safe, non-null, oldest-to-newest snapshot only after its complete persisted history has been validated and loaded. Each type-1 point SHALL contain its persisted or sampled timestamp, the Evelyn Price Index, and `BigDecimal.ZERO` placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use independently named files, history collections, reservations, and lifecycles. A sampled point SHALL be durably appended using robust line-boundary handling before publication. Stop SHALL clear only memory; restart SHALL restore the complete file, and close SHALL preserve the file.
|
||||||
|
|
||||||
|
#### Scenario: Status-index history is requested during sampling
|
||||||
|
- **WHEN** a caller requests an Evelyn environment's status-index history while persisted points may be appended
|
||||||
|
- **THEN** it receives a safe snapshot containing only completely persisted points and all six typed fields
|
||||||
|
|
||||||
|
#### Scenario: A process starts two named Evelyn environments
|
||||||
|
- **WHEN** Production and Test Evelyn instances start with independently provisioned files
|
||||||
|
- **THEN** each restores and extends only its own complete history even if calculated values are identical
|
||||||
|
|
||||||
|
#### Scenario: Existing file lacks a final line separator
|
||||||
|
- **WHEN** a valid existing status file ends with a record but no line separator
|
||||||
|
- **THEN** the next durable append first supplies a line boundary and preserves both records as distinct entries
|
||||||
|
|
||||||
|
#### Scenario: An Evelyn instance stops and restarts
|
||||||
|
- **WHEN** a named instance is stopped and later restarted
|
||||||
|
- **THEN** its memory is cleared at stop and its complete unchanged persistent history is revalidated and restored before sampling resumes
|
||||||
|
|
||||||
|
### Requirement: Evelyn Mission Control visualizes status-index measurements
|
||||||
|
Evelyn Mission Control SHALL display separate live Production and Test Overview charts exclusively from their corresponding authoritative Evelyn snapshots and SHALL never read or manage persistence directly. Each chart SHALL contain one enabled series named `Evelyn Price Index`; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete restored current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, close, persist, or clear either Evelyn environment.
|
||||||
|
|
||||||
|
#### Scenario: Production and Test Overviews show restored history
|
||||||
|
- **WHEN** Mission Control opens after two named Evelyn environments have restored their histories
|
||||||
|
- **THEN** it displays separate one-series charts immediately populated from the corresponding complete snapshots
|
||||||
|
|
||||||
|
#### Scenario: A successful point appears while EMC is open
|
||||||
|
- **WHEN** an Evelyn history publishes a newly persisted point
|
||||||
|
- **THEN** the corresponding chart adds it on the JavaFX Application Thread and updates its timestamp and symmetric price-index bounds without reopening EMC
|
||||||
|
|
||||||
|
#### Scenario: EMC reopens after collecting hidden-window samples
|
||||||
|
- **WHEN** the same EMC instance is reopened after Evelyn persisted additional points while its window was hidden
|
||||||
|
- **THEN** each chart is reconstructed immediately from its environment's complete current history without missing points because of stale rendering state
|
||||||
|
|
||||||
|
#### Scenario: Future fields contain larger values
|
||||||
|
- **WHEN** any disabled future-index field has a greater absolute value than Evelyn Price Index
|
||||||
|
- **THEN** that disabled value does not affect the visible Y-axis bounds
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
## 1. Named Identity and Lifecycle
|
||||||
|
|
||||||
|
- [x] 1.1 Add NFC portable-name validation, direct-child path derivation, and atomic case-insensitive JVM-local reservation after all constructor validation.
|
||||||
|
- [x] 1.2 Extend the public Evelyn API with exception-free `AutoCloseable.close()` and implement restartable stop versus permanent idempotent close without releasing a possibly live writer.
|
||||||
|
- [x] 1.3 Update all required constructors and Production/Test composition to use independent named instances without enabling unrelated services.
|
||||||
|
|
||||||
|
## 2. Strict Storage Restoration
|
||||||
|
|
||||||
|
- [x] 2.1 Validate the externally provisioned per-instance directory and `status.log` type/access on every start without creating or modifying them.
|
||||||
|
- [x] 2.2 Validate dedicated status format version 1 as the first actual entry before parsing records, with path and declaration diagnostics.
|
||||||
|
- [x] 2.3 Strictly decode every type-1 record, reject unknown types and malformed field counts/values, enforce non-decreasing timestamps, and publish only a completely validated temporary history.
|
||||||
|
|
||||||
|
## 3. Durable Sampling Persistence
|
||||||
|
|
||||||
|
- [x] 3.1 Encode stable type-1 records with UTC millisecond timestamps and plain decimals, preserving blank/comment parsing semantics.
|
||||||
|
- [x] 3.2 Durably append before in-memory publication, including missing-final-line-separator handling and rejection of backward sampled timestamps.
|
||||||
|
- [x] 3.3 Isolate append/flush failures with instance, path, and sampling diagnostics so later fixed-delay attempts continue.
|
||||||
|
|
||||||
|
## 4. Verification and Review
|
||||||
|
|
||||||
|
- [x] 4.1 Compile main and test source sets without running or adding automated tests.
|
||||||
|
- [x] 4.2 Run strict validation for the active OpenSpec change and `git diff --check`.
|
||||||
|
- [x] 4.3 Review the complete diff for unrelated changes, behavioral gaps, generated Java edits, runtime data, and changes to Ticker/Raydium specifications or persistence.
|
||||||
@@ -7,10 +7,10 @@ Provide typed process-local Evelyn status measurements and visualize the current
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Evelyn calculates the live price index
|
### Requirement: Evelyn calculates the live price index
|
||||||
Evelyn SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker observation for the canonical EVE/USDT trading pair. It SHALL treat USDT as USD for this calculation and SHALL NOT access Raydium directly. At sampling instant `t`, on or after `2026-07-31T22:00:00Z`, the expected price SHALL be `15 × 1.20^(elapsedSeconds / 31,536,000)` and the Evelyn Price Index SHALL be `((actualPrice / expectedPrice) - 1) × 10`, calculated with adequate decimal precision and without arbitrary history rounding.
|
Evelyn SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker observation for the canonical EVE/USDT trading pair. It SHALL treat USDT as USD for this calculation and SHALL NOT access Raydium directly. At sampling instant t, on or after 2026-07-31T22:00:00Z, the expected price SHALL be 15 × 1.20^(elapsedSeconds / 31,536,000) and the Evelyn Price Index SHALL be ((actualPrice / expectedPrice) - 1) × 10, calculated with adequate decimal precision and without arbitrary history rounding.
|
||||||
|
|
||||||
#### Scenario: Calculate expected price at the configured start
|
#### Scenario: Calculate expected price at the configured start
|
||||||
- **WHEN** the sampling instant is `2026-07-31T22:00:00Z`
|
- **WHEN** the sampling instant is 2026-07-31T22:00:00Z
|
||||||
- **THEN** the expected EVE price is exactly 15 USD
|
- **THEN** the expected EVE price is exactly 15 USD
|
||||||
|
|
||||||
#### Scenario: Calculate expected price after one year
|
#### Scenario: Calculate expected price after one year
|
||||||
@@ -19,55 +19,125 @@ Evelyn SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker obs
|
|||||||
|
|
||||||
#### Scenario: Calculate representative fractional-year growth
|
#### Scenario: Calculate representative fractional-year growth
|
||||||
- **WHEN** 18 days, 3 hours, 8 minutes, and 14 seconds have elapsed
|
- **WHEN** 18 days, 3 hours, 8 minutes, and 14 seconds have elapsed
|
||||||
- **THEN** the expected EVE price is approximately `15.136464436276` USD
|
- **THEN** the expected EVE price is approximately 15.136464436276 USD
|
||||||
|
|
||||||
#### Scenario: Calculate index from actual price
|
#### Scenario: Calculate index from actual price
|
||||||
- **WHEN** actual price equals expected price, is 10 percent above, is 10 percent below, is double, or is half the expected price
|
- **WHEN** actual price equals expected price, is 10 percent above, is 10 percent below, is double, or is half the expected price
|
||||||
- **THEN** the respective index is 0, 1, -1, 10, or -5
|
- **THEN** the respective index is 0, 1, -1, 10, or -5
|
||||||
|
|
||||||
|
### Requirement: Evelyn instances have safe unique persistent names
|
||||||
|
Every Evelyn instance SHALL have an NFC-normalized Unicode name whose preserved spelling and capitalization selects the direct child directory data/evelyn/<name>. A name SHALL be rejected if it is empty or whitespace-only, has leading or trailing whitespace, is . or .., ends with a period, contains a control character or any of < > : " / \ | ? *, is a case-insensitive Windows-reserved filename including a reserved base with an extension, or normalizes as a path to anything other than one direct child of data/evelyn. Within one JVM, normalized names SHALL be reserved atomically and compared case-insensitively using locale-independent rules.
|
||||||
|
|
||||||
|
#### Scenario: A portable Unicode name is accepted
|
||||||
|
- **WHEN** an instance is constructed with a valid Unicode name containing Danish characters
|
||||||
|
- **THEN** NFC normalization is applied and the normalized spelling and capitalization identify its direct child data directory
|
||||||
|
|
||||||
|
#### Scenario: An unsafe name is rejected without reservation
|
||||||
|
- **WHEN** construction receives an invalid name or any other invalid constructor argument
|
||||||
|
- **THEN** construction fails before reserving a persistent identity
|
||||||
|
|
||||||
|
#### Scenario: Concurrent equivalent names conflict
|
||||||
|
- **WHEN** two constructor calls in one JVM concurrently request names that are equal after NFC normalization and locale-independent case folding
|
||||||
|
- **THEN** exactly one reserves the persistent identity and the other fails
|
||||||
|
|
||||||
|
### Requirement: Evelyn distinguishes restartable stop from permanent close
|
||||||
|
Stopping Evelyn SHALL terminate sampling, clear memory, preserve its persistent file, and retain its name reservation so the same object can restart. Evelyn SHALL support permanent, idempotent close; closing an active instance SHALL first stop it safely, a successfully closed instance SHALL never start again, and its name SHALL be released only after its sampling thread has definitely terminated. A termination failure SHALL be reported and SHALL retain the reservation.
|
||||||
|
|
||||||
|
#### Scenario: A stopped instance restarts
|
||||||
|
- **WHEN** a stopped but not closed Evelyn instance is started again
|
||||||
|
- **THEN** it retains exclusive ownership of the same name and reloads the complete persisted history before sampling
|
||||||
|
|
||||||
|
#### Scenario: An active instance closes successfully
|
||||||
|
- **WHEN** close is called on an active instance and its sampling thread terminates
|
||||||
|
- **THEN** memory is cleared, the name reservation is released, and later start calls fail
|
||||||
|
|
||||||
|
#### Scenario: Close cannot confirm termination
|
||||||
|
- **WHEN** close cannot confirm that the sampling thread terminated
|
||||||
|
- **THEN** close reports failure and retains the name reservation
|
||||||
|
|
||||||
|
#### Scenario: Close is repeated
|
||||||
|
- **WHEN** close is called after the instance was successfully closed
|
||||||
|
- **THEN** it completes without changing state or failing
|
||||||
|
|
||||||
|
### Requirement: Evelyn uses externally provisioned per-instance storage
|
||||||
|
Each named instance SHALL use the existing regular file data/evelyn/<normalized-name>/status.log. Before every start, Evelyn SHALL require the direct instance path to be an existing readable and writable directory and status.log to be an existing readable and writable regular file. Evelyn SHALL NOT create, replace, repair, truncate, rename, or otherwise provision either path. Any validation or actual open failure SHALL identify the affected path, leave memory unpublished, and prevent sampling from starting.
|
||||||
|
|
||||||
|
#### Scenario: Required storage is absent or unsuitable
|
||||||
|
- **WHEN** the instance directory or status file is missing, the path has the wrong type, or required access is unavailable
|
||||||
|
- **THEN** start fails with the affected path and reason without modifying storage, publishing history, or starting sampling
|
||||||
|
|
||||||
|
#### Scenario: Storage changes while stopped
|
||||||
|
- **WHEN** storage becomes invalid after stop and the same instance is started again
|
||||||
|
- **THEN** the complete startup validation is repeated and restart fails without modifying storage
|
||||||
|
|
||||||
|
### Requirement: Evelyn validates status history format strictly
|
||||||
|
The UTF-8 status.log format SHALL have supported version 1, declared by FORMAT_VERSION=1 as the first actual entry; blank lines and full-line comments MAY precede it when loading, while an externally provisioned new file SHALL place it on the physical first line. Missing, duplicate, malformed, misplaced, or unsupported declarations SHALL fail loading before record parsing. After the declaration, blank lines and comments SHALL be ignored, inline comments MAY follow data, and every other entry SHALL be a valid record with an explicit stable numeric type code.
|
||||||
|
|
||||||
|
Type 1 SHALL encode <UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Evelyn Price Index>. It SHALL have exactly three fields. Unknown types, invalid timestamps or decimals, missing or surplus fields, and decreasing timestamps SHALL fail the complete load; equal timestamps SHALL be accepted. A valid declaration with no records SHALL represent empty history. Applicable failures SHALL identify the file path, one-based line number, and original line, and Evelyn SHALL neither skip, sort, partially publish, nor repair invalid content.
|
||||||
|
|
||||||
|
#### Scenario: A valid versioned history loads
|
||||||
|
- **WHEN** the file has a valid first actual version declaration and valid type-1 records in non-decreasing timestamp order
|
||||||
|
- **THEN** Evelyn restores every record in file order with zero values for the five unimplemented indexes
|
||||||
|
|
||||||
|
#### Scenario: The format declaration is invalid
|
||||||
|
- **WHEN** the declaration is missing, duplicated, malformed, misplaced, or not version 1
|
||||||
|
- **THEN** startup fails before parsing records and reports useful file diagnostics
|
||||||
|
|
||||||
|
#### Scenario: A record is invalid
|
||||||
|
- **WHEN** a record has an unknown type, malformed field, wrong field count, or timestamp earlier than its predecessor
|
||||||
|
- **THEN** startup fails with path, line number, and original line without exposing partial history or altering the file
|
||||||
|
|
||||||
### Requirement: Evelyn samples without overlapping or catch-up attempts
|
### Requirement: Evelyn samples without overlapping or catch-up attempts
|
||||||
Each started Evelyn environment SHALL own its sampling executor, attempt its first sample immediately, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the Evelyn lifecycle independently of whether Mission Control is open. Evelyn SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup without adding a fake point, terminating Evelyn, or cancelling the next attempt. Stopping Evelyn SHALL terminate its sampling executor.
|
Each started Evelyn environment SHALL own its sampling executor, attempt its first sample only after complete storage validation and history restoration, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the Evelyn lifecycle independently of whether Mission Control is open. Evelyn SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup or persistence operation without adding a fake or unpersisted point, terminating Evelyn, or cancelling the next attempt. Stopping Evelyn SHALL terminate its sampling executor.
|
||||||
|
|
||||||
#### Scenario: Initial Ticker data is unavailable
|
#### Scenario: Initial Ticker data is unavailable
|
||||||
- **WHEN** the immediate sampling attempt cannot obtain a latest EVE/USDT price
|
- **WHEN** the immediate sampling attempt cannot obtain a latest EVE/USDT price
|
||||||
- **THEN** no point is added, diagnostic context is logged, and the next fixed-delay attempt remains scheduled
|
- **THEN** no point is persisted or published, diagnostic context is logged, and the next fixed-delay attempt remains scheduled
|
||||||
|
|
||||||
#### Scenario: A later sample succeeds
|
#### Scenario: A later sample succeeds
|
||||||
- **WHEN** a later attempt obtains a latest EVE/USDT price
|
- **WHEN** a later attempt obtains a latest EVE/USDT price whose millisecond timestamp is not earlier than the last persisted timestamp
|
||||||
- **THEN** exactly one point is appended using the sampling instant for both expected-price calculation and point timestamp
|
- **THEN** exactly one type-1 record is durably appended before the corresponding point becomes visible in memory
|
||||||
|
|
||||||
|
#### Scenario: Persistence fails for one sample
|
||||||
|
- **WHEN** appending or durably flushing a calculated point fails
|
||||||
|
- **THEN** the point is not published, instance and path context are logged, and the next fixed-delay attempt remains scheduled
|
||||||
|
|
||||||
|
#### Scenario: A sampled timestamp goes backwards
|
||||||
|
- **WHEN** a newly sampled timestamp is earlier than the last persisted timestamp
|
||||||
|
- **THEN** no record or point is added, the failure is logged, and later attempts remain scheduled
|
||||||
|
|
||||||
### Requirement: Evelyn provides historical status-index measurements
|
### Requirement: Evelyn provides historical status-index measurements
|
||||||
Each Evelyn instance SHALL begin with an empty, process-local in-memory history and expose a safe, non-null snapshot of typed measurement points ordered from oldest to newest while sampling may continue concurrently. Each successful point SHALL contain its sampling timestamp, the calculated Evelyn Price Index, and `BigDecimal.ZERO` placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use separate history collections, and no point SHALL be persisted, restored, or backfilled.
|
Each named Evelyn instance SHALL publish a safe, non-null, oldest-to-newest snapshot only after its complete persisted history has been validated and loaded. Each type-1 point SHALL contain its persisted or sampled timestamp, the Evelyn Price Index, and BigDecimal.ZERO placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use independently named files, history collections, reservations, and lifecycles. A sampled point SHALL be durably appended using robust line-boundary handling before publication. Stop SHALL clear only memory; restart SHALL restore the complete file, and close SHALL preserve the file.
|
||||||
|
|
||||||
#### Scenario: Status-index history is requested during sampling
|
#### Scenario: Status-index history is requested during sampling
|
||||||
- **WHEN** a caller requests an Evelyn environment's status-index history while points may be appended
|
- **WHEN** a caller requests an Evelyn environment's status-index history while persisted points may be appended
|
||||||
- **THEN** it receives a safe, non-null, oldest-to-newest snapshot containing all six typed fields
|
- **THEN** it receives a safe snapshot containing only completely persisted points and all six typed fields
|
||||||
|
|
||||||
#### Scenario: A process starts two Evelyn environments
|
#### Scenario: A process starts two named Evelyn environments
|
||||||
- **WHEN** Production and Test Evelyn instances are created for a new process
|
- **WHEN** Production and Test Evelyn instances start with independently provisioned files
|
||||||
- **THEN** both histories start empty and remain separate even if their calculated values are identical
|
- **THEN** each restores and extends only its own complete history even if calculated values are identical
|
||||||
|
|
||||||
#### Scenario: A real point is sampled
|
#### Scenario: Existing file lacks a final line separator
|
||||||
- **WHEN** Evelyn appends a successful price-index point
|
- **WHEN** a valid existing status file ends with a record but no line separator
|
||||||
- **THEN** its Evelyn Price Index contains the calculated value and each of the other five fields contains zero
|
- **THEN** the next durable append first supplies a line boundary and preserves both records as distinct entries
|
||||||
|
|
||||||
#### Scenario: An Evelyn instance is stopped
|
#### Scenario: An Evelyn instance stops and restarts
|
||||||
- **WHEN** a Production or Test Evelyn instance is stopped
|
- **WHEN** a named instance is stopped and later restarted
|
||||||
- **THEN** its sampling executor terminates and its complete process-local history is discarded without affecting the other environment
|
- **THEN** its memory is cleared at stop and its complete unchanged persistent history is revalidated and restored before sampling resumes
|
||||||
|
|
||||||
### Requirement: Evelyn Mission Control visualizes status-index measurements
|
### Requirement: Evelyn Mission Control visualizes status-index measurements
|
||||||
Evelyn Mission Control SHALL display separate live Production and Test Overview charts from their corresponding authoritative Evelyn snapshots. Each chart SHALL contain one enabled series named `Evelyn Price Index`; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, or clear either Evelyn environment.
|
Evelyn Mission Control SHALL display separate live Production and Test Overview charts exclusively from their corresponding authoritative Evelyn snapshots and SHALL never read or manage persistence directly. Each chart SHALL contain one enabled series named Evelyn Price Index; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete restored current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, close, persist, or clear either Evelyn environment.
|
||||||
|
|
||||||
#### Scenario: Production and Test Overviews start empty
|
#### Scenario: Production and Test Overviews show restored history
|
||||||
- **WHEN** Mission Control opens with two newly created Evelyn environments
|
- **WHEN** Mission Control opens after two named Evelyn environments have restored their histories
|
||||||
- **THEN** it displays two separate empty one-series charts with valid timestamp axes and symmetric Y-axes containing zero
|
- **THEN** it displays separate one-series charts immediately populated from the corresponding complete snapshots
|
||||||
|
|
||||||
#### Scenario: A successful point appears while EMC is open
|
#### Scenario: A successful point appears while EMC is open
|
||||||
- **WHEN** an Evelyn history receives a new successful point
|
- **WHEN** an Evelyn history publishes a newly persisted point
|
||||||
- **THEN** the corresponding chart adds it on the JavaFX Application Thread and updates its timestamp and symmetric price-index bounds without reopening EMC
|
- **THEN** the corresponding chart adds it on the JavaFX Application Thread and updates its timestamp and symmetric price-index bounds without reopening EMC
|
||||||
|
|
||||||
#### Scenario: EMC reopens after collecting hidden-window samples
|
#### Scenario: EMC reopens after collecting hidden-window samples
|
||||||
- **WHEN** the same EMC instance is reopened after Evelyn collected additional points while its window was hidden
|
- **WHEN** the same EMC instance is reopened after Evelyn persisted additional points while its window was hidden
|
||||||
- **THEN** each chart is reconstructed immediately from its environment's complete current history without missing points because of stale rendering state
|
- **THEN** each chart is reconstructed immediately from its environment's complete current history without missing points because of stale rendering state
|
||||||
|
|
||||||
#### Scenario: Future fields contain larger values
|
#### Scenario: Future fields contain larger values
|
||||||
|
|||||||
@@ -4,12 +4,37 @@ import org.jetbrains.annotations.NotNull;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface Evelyn {
|
/**
|
||||||
|
* Collects and exposes the status-index history for one named Evelyn instance.
|
||||||
|
*/
|
||||||
|
public interface Evelyn extends AutoCloseable {
|
||||||
|
/**
|
||||||
|
* Executes the legacy Evelyn service operation.
|
||||||
|
*
|
||||||
|
* @throws Exception when service execution fails
|
||||||
|
*/
|
||||||
void executeService() throws Exception;
|
void executeService() throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores persisted history and starts status-index sampling.
|
||||||
|
*/
|
||||||
void start();
|
void start();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops sampling and clears memory while retaining persistent ownership.
|
||||||
|
*/
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently closes this instance and releases its persistent name.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an immutable snapshot containing only durably persisted points.
|
||||||
|
*
|
||||||
|
* @return complete current history ordered from oldest to newest
|
||||||
|
*/
|
||||||
@NotNull List<EvelynStatusIndexPoint> getStatusIndexHistory();
|
@NotNull List<EvelynStatusIndexPoint> getStatusIndexHistory();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,28 @@ import org.jetbrains.annotations.NotNull;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.text.Normalizer;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.ResolverStyle;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
||||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
||||||
@@ -46,36 +59,78 @@ public class EvelynImpl implements Evelyn {
|
|||||||
this.solanaChain = solanaChain;
|
this.solanaChain = solanaChain;
|
||||||
}*/
|
}*/
|
||||||
public EvelynImpl(
|
public EvelynImpl(
|
||||||
|
@NotNull String instanceName,
|
||||||
@NotNull TickerService tickerService,
|
@NotNull TickerService tickerService,
|
||||||
@NotNull TradingPair eveUsdtTradingPair
|
@NotNull TradingPair eveUsdtTradingPair
|
||||||
) {
|
) {
|
||||||
this(
|
this(
|
||||||
|
instanceName,
|
||||||
tickerService,
|
tickerService,
|
||||||
eveUsdtTradingPair,
|
eveUsdtTradingPair,
|
||||||
Clock.systemUTC(),
|
Clock.systemUTC(),
|
||||||
SAMPLE_DELAY_MINUTES,
|
SAMPLE_DELAY_MINUTES,
|
||||||
TimeUnit.MINUTES
|
TimeUnit.MINUTES,
|
||||||
|
DATA_ROOT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
EvelynImpl(
|
EvelynImpl(
|
||||||
|
@NotNull String instanceName,
|
||||||
@NotNull TickerService tickerService,
|
@NotNull TickerService tickerService,
|
||||||
@NotNull TradingPair eveUsdtTradingPair,
|
@NotNull TradingPair eveUsdtTradingPair,
|
||||||
@NotNull Clock clock,
|
@NotNull Clock clock,
|
||||||
long sampleDelay,
|
long sampleDelay,
|
||||||
@NotNull TimeUnit sampleDelayUnit
|
@NotNull TimeUnit sampleDelayUnit,
|
||||||
|
@NotNull Path dataRoot
|
||||||
) {
|
) {
|
||||||
this.tickerService = Objects.requireNonNull(tickerService, "tickerService");
|
TickerService validatedTickerService = Objects.requireNonNull(
|
||||||
this.eveUsdtTradingPair = Objects.requireNonNull(
|
tickerService,
|
||||||
|
"tickerService"
|
||||||
|
);
|
||||||
|
TradingPair validatedTradingPair = Objects.requireNonNull(
|
||||||
eveUsdtTradingPair,
|
eveUsdtTradingPair,
|
||||||
"eveUsdtTradingPair"
|
"eveUsdtTradingPair"
|
||||||
);
|
);
|
||||||
this.clock = Objects.requireNonNull(clock, "clock");
|
Clock validatedClock = Objects.requireNonNull(clock, "clock");
|
||||||
if (sampleDelay <= 0) {
|
if (sampleDelay <= 0) {
|
||||||
throw new IllegalArgumentException("sampleDelay must be positive");
|
throw new IllegalArgumentException("sampleDelay must be positive");
|
||||||
}
|
}
|
||||||
|
TimeUnit validatedDelayUnit = Objects.requireNonNull(
|
||||||
|
sampleDelayUnit,
|
||||||
|
"sampleDelayUnit"
|
||||||
|
);
|
||||||
|
Path validatedDataRoot = Objects.requireNonNull(dataRoot, "dataRoot").normalize();
|
||||||
|
String normalizedInstanceName = validateInstanceName(instanceName);
|
||||||
|
Path validatedInstancePath = validatedDataRoot
|
||||||
|
.resolve(normalizedInstanceName)
|
||||||
|
.normalize();
|
||||||
|
if (!validatedDataRoot.equals(validatedInstancePath.getParent())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Evelyn instance name must resolve to a direct child of "
|
||||||
|
+ validatedDataRoot + ": " + normalizedInstanceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Path validatedStatusHistoryPath = validatedInstancePath.resolve(
|
||||||
|
STATUS_HISTORY_FILENAME
|
||||||
|
);
|
||||||
|
String reservationKey = normalizedInstanceName.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
if (!RESERVED_INSTANCE_NAMES.add(reservationKey)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Evelyn instance name is already reserved in this JVM: "
|
||||||
|
+ normalizedInstanceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instanceName = normalizedInstanceName;
|
||||||
|
this.reservationKey = reservationKey;
|
||||||
|
this.instancePath = validatedInstancePath;
|
||||||
|
this.statusHistoryPath = validatedStatusHistoryPath;
|
||||||
|
this.tickerService = validatedTickerService;
|
||||||
|
this.eveUsdtTradingPair = validatedTradingPair;
|
||||||
|
this.clock = validatedClock;
|
||||||
this.sampleDelay = sampleDelay;
|
this.sampleDelay = sampleDelay;
|
||||||
this.sampleDelayUnit = Objects.requireNonNull(sampleDelayUnit, "sampleDelayUnit");
|
this.sampleDelayUnit = validatedDelayUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -85,59 +140,161 @@ public class EvelynImpl implements Evelyn {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void start() {
|
public synchronized void start() {
|
||||||
if (statusIndexScheduler != null || stoppingStatusIndexSampling) {
|
if (closeRequested || lifecycleState == LifecycleState.CLOSED) {
|
||||||
throw new IllegalStateException("Evelyn is already started");
|
throw new IllegalStateException(
|
||||||
|
"Closing or closed Evelyn instance cannot be started: "
|
||||||
|
+ instanceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lifecycleState != LifecycleState.STOPPED) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Evelyn instance cannot start while in state "
|
||||||
|
+ lifecycleState + ": " + instanceName
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduledExecutorService newScheduler = Executors.newSingleThreadScheduledExecutor(
|
lifecycleState = LifecycleState.STARTING;
|
||||||
runnable -> {
|
LoadedHistory loadedHistory;
|
||||||
Thread thread = new Thread(runnable, "evelyn-price-index-sampler");
|
try {
|
||||||
thread.setDaemon(true);
|
loadedHistory = loadStatusHistory();
|
||||||
return thread;
|
} catch (IOException | RuntimeException exception) {
|
||||||
}
|
clearStatusIndexHistory();
|
||||||
);
|
lifecycleState = LifecycleState.STOPPED;
|
||||||
statusIndexScheduler = newScheduler;
|
throw new IllegalStateException(
|
||||||
newScheduler.scheduleWithFixedDelay(
|
"Could not start Evelyn instance '" + instanceName
|
||||||
this::sampleStatusIndexSafely,
|
+ "' from " + statusHistoryPath + ": "
|
||||||
0,
|
+ exception.getMessage(),
|
||||||
sampleDelay,
|
exception
|
||||||
sampleDelayUnit
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
ScheduledExecutorService newScheduler;
|
||||||
|
try {
|
||||||
|
newScheduler = Executors.newSingleThreadScheduledExecutor(
|
||||||
|
runnable -> {
|
||||||
|
Thread thread = new Thread(
|
||||||
|
runnable,
|
||||||
|
"evelyn-price-index-sampler-" + instanceName
|
||||||
|
);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
clearStatusIndexHistory();
|
||||||
|
lifecycleState = LifecycleState.STOPPED;
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Could not create Evelyn sampling executor for instance '"
|
||||||
|
+ instanceName + "'",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
synchronized (statusIndexHistory) {
|
||||||
|
statusIndexHistory.clear();
|
||||||
|
statusIndexHistory.addAll(loadedHistory.points());
|
||||||
|
lastPersistedTimestamp = loadedHistory.lastTimestamp();
|
||||||
|
}
|
||||||
|
statusIndexScheduler = newScheduler;
|
||||||
|
lifecycleState = LifecycleState.STARTED;
|
||||||
|
newScheduler.scheduleWithFixedDelay(
|
||||||
|
this::sampleStatusIndexSafely,
|
||||||
|
0,
|
||||||
|
sampleDelay,
|
||||||
|
sampleDelayUnit
|
||||||
|
);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
lifecycleState = LifecycleState.STOPPING;
|
||||||
|
newScheduler.shutdownNow();
|
||||||
|
statusIndexScheduler = null;
|
||||||
|
clearStatusIndexHistory();
|
||||||
|
lifecycleState = LifecycleState.STOPPED;
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Could not start Evelyn sampling for instance '"
|
||||||
|
+ instanceName + "'",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
ScheduledExecutorService schedulerToStop;
|
ScheduledExecutorService schedulerToStop;
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (statusIndexScheduler == null) {
|
if (lifecycleState == LifecycleState.CLOSED
|
||||||
|
|| lifecycleState == LifecycleState.STOPPED) {
|
||||||
clearStatusIndexHistory();
|
clearStatusIndexHistory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stoppingStatusIndexSampling = true;
|
if (lifecycleState != LifecycleState.STARTED
|
||||||
|
&& lifecycleState != LifecycleState.TERMINATION_FAILED) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Evelyn instance cannot stop while in state "
|
||||||
|
+ lifecycleState + ": " + instanceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lifecycleState = LifecycleState.STOPPING;
|
||||||
schedulerToStop = statusIndexScheduler;
|
schedulerToStop = statusIndexScheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
schedulerToStop.shutdownNow();
|
schedulerToStop.shutdownNow();
|
||||||
boolean terminated = false;
|
boolean terminated;
|
||||||
try {
|
try {
|
||||||
terminated = schedulerToStop.awaitTermination(
|
terminated = schedulerToStop.awaitTermination(
|
||||||
TERMINATION_TIMEOUT_SECONDS,
|
TERMINATION_TIMEOUT_SECONDS,
|
||||||
TimeUnit.SECONDS
|
TimeUnit.SECONDS
|
||||||
);
|
);
|
||||||
if (!terminated) {
|
|
||||||
log.error("Evelyn status-index scheduler did not terminate");
|
|
||||||
}
|
|
||||||
} catch (InterruptedException exception) {
|
} catch (InterruptedException exception) {
|
||||||
schedulerToStop.shutdownNow();
|
schedulerToStop.shutdownNow();
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
} finally {
|
|
||||||
clearStatusIndexHistory();
|
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (terminated || schedulerToStop.isTerminated()) {
|
lifecycleState = LifecycleState.TERMINATION_FAILED;
|
||||||
statusIndexScheduler = null;
|
|
||||||
stoppingStatusIndexSampling = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Interrupted while stopping Evelyn instance '"
|
||||||
|
+ instanceName + "'; name reservation retained",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!terminated) {
|
||||||
|
synchronized (this) {
|
||||||
|
lifecycleState = LifecycleState.TERMINATION_FAILED;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Evelyn sampling thread did not terminate for instance '"
|
||||||
|
+ instanceName + "'; name reservation retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStatusIndexHistory();
|
||||||
|
synchronized (this) {
|
||||||
|
statusIndexScheduler = null;
|
||||||
|
lifecycleState = LifecycleState.STOPPED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
synchronized (this) {
|
||||||
|
if (lifecycleState == LifecycleState.CLOSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop();
|
||||||
|
|
||||||
|
synchronized (this) {
|
||||||
|
if (lifecycleState != LifecycleState.STOPPED
|
||||||
|
|| statusIndexScheduler != null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot close Evelyn instance while sampling termination "
|
||||||
|
+ "is unconfirmed: " + instanceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lifecycleState = LifecycleState.CLOSED;
|
||||||
|
RESERVED_INSTANCE_NAMES.remove(reservationKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +308,275 @@ public class EvelynImpl implements Evelyn {
|
|||||||
private void clearStatusIndexHistory() {
|
private void clearStatusIndexHistory() {
|
||||||
synchronized (statusIndexHistory) {
|
synchronized (statusIndexHistory) {
|
||||||
statusIndexHistory.clear();
|
statusIndexHistory.clear();
|
||||||
|
lastPersistedTimestamp = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sampleStatusIndexSafely() {
|
private LoadedHistory loadStatusHistory() throws IOException {
|
||||||
|
validateStorage();
|
||||||
|
List<String> lines = Files.readAllLines(
|
||||||
|
statusHistoryPath,
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
);
|
||||||
|
int formatVersionLineIndex = validateStatusFormatVersion(lines);
|
||||||
|
List<EvelynStatusIndexPoint> loadedPoints = new ArrayList<>();
|
||||||
|
Instant previousTimestamp = null;
|
||||||
|
|
||||||
|
for (int lineIndex = formatVersionLineIndex + 1;
|
||||||
|
lineIndex < lines.size();
|
||||||
|
lineIndex++) {
|
||||||
|
String rawLine = lines.get(lineIndex);
|
||||||
|
String data = removeComment(rawLine).trim();
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
EvelynStatusIndexPoint point = parseStatusRecord(
|
||||||
|
data,
|
||||||
|
rawLine,
|
||||||
|
lineIndex
|
||||||
|
);
|
||||||
|
if (previousTimestamp != null
|
||||||
|
&& point.timestamp().isBefore(previousTimestamp)) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"timestamp is earlier than the preceding record",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loadedPoints.add(point);
|
||||||
|
previousTimestamp = point.timestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LoadedHistory(List.copyOf(loadedPoints), previousTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateStorage() throws IOException {
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.exists(instancePath),
|
||||||
|
instancePath,
|
||||||
|
"instance directory does not exist"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isDirectory(instancePath),
|
||||||
|
instancePath,
|
||||||
|
"instance path is not a directory"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isReadable(instancePath),
|
||||||
|
instancePath,
|
||||||
|
"instance directory is not readable"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isWritable(instancePath),
|
||||||
|
instancePath,
|
||||||
|
"instance directory is not writable"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.exists(statusHistoryPath),
|
||||||
|
statusHistoryPath,
|
||||||
|
"status history file does not exist"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isRegularFile(statusHistoryPath),
|
||||||
|
statusHistoryPath,
|
||||||
|
"status history path is not a regular file"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isReadable(statusHistoryPath),
|
||||||
|
statusHistoryPath,
|
||||||
|
"status history file is not readable"
|
||||||
|
);
|
||||||
|
requireStorageCondition(
|
||||||
|
Files.isWritable(statusHistoryPath),
|
||||||
|
statusHistoryPath,
|
||||||
|
"status history file is not writable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireStorageCondition(
|
||||||
|
boolean condition,
|
||||||
|
Path path,
|
||||||
|
String reason
|
||||||
|
) throws IOException {
|
||||||
|
if (!condition) {
|
||||||
|
throw new IOException(path + ": " + reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int validateStatusFormatVersion(List<String> lines) throws IOException {
|
||||||
|
int firstActualEntryIndex = -1;
|
||||||
|
int declarationIndex = -1;
|
||||||
|
int declarationCount = 0;
|
||||||
|
int declaredVersion = -1;
|
||||||
|
|
||||||
|
for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) {
|
||||||
|
String rawLine = lines.get(lineIndex);
|
||||||
|
String trimmedLine = rawLine.trim();
|
||||||
|
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstActualEntryIndex < 0) {
|
||||||
|
firstActualEntryIndex = lineIndex;
|
||||||
|
}
|
||||||
|
if (!trimmedLine.startsWith(FORMAT_VERSION_KEY)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
declarationCount++;
|
||||||
|
if (declarationCount > 1) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"duplicate " + FORMAT_VERSION_KEY + " declaration",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
declarationIndex = lineIndex;
|
||||||
|
String expectedPrefix = FORMAT_VERSION_KEY + "=";
|
||||||
|
if (!trimmedLine.startsWith(expectedPrefix)
|
||||||
|
|| trimmedLine.length() == expectedPrefix.length()) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"malformed " + FORMAT_VERSION_KEY
|
||||||
|
+ " declaration; expected "
|
||||||
|
+ FORMAT_VERSION_KEY + "=<positive integer>",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
declaredVersion = Integer.parseInt(
|
||||||
|
trimmedLine.substring(expectedPrefix.length())
|
||||||
|
);
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"malformed " + FORMAT_VERSION_KEY
|
||||||
|
+ " declaration; expected "
|
||||||
|
+ FORMAT_VERSION_KEY + "=<positive integer>",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (declaredVersion <= 0) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
FORMAT_VERSION_KEY + " must be a positive integer",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declarationCount == 0) {
|
||||||
|
if (firstActualEntryIndex >= 0) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
firstActualEntryIndex,
|
||||||
|
lines.get(firstActualEntryIndex),
|
||||||
|
"missing " + FORMAT_VERSION_KEY
|
||||||
|
+ " declaration; it must be the first actual entry",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new IOException(
|
||||||
|
statusHistoryPath + ": missing " + FORMAT_VERSION_KEY
|
||||||
|
+ " declaration; it must be the first actual entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (declarationIndex != firstActualEntryIndex) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
declarationIndex,
|
||||||
|
lines.get(declarationIndex),
|
||||||
|
FORMAT_VERSION_KEY + " must be the first actual entry",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (declaredVersion != SUPPORTED_STATUS_HISTORY_FORMAT_VERSION) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
declarationIndex,
|
||||||
|
lines.get(declarationIndex),
|
||||||
|
"unsupported status history format version "
|
||||||
|
+ declaredVersion + "; supported version is "
|
||||||
|
+ SUPPORTED_STATUS_HISTORY_FORMAT_VERSION,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return declarationIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EvelynStatusIndexPoint parseStatusRecord(
|
||||||
|
String data,
|
||||||
|
String rawLine,
|
||||||
|
int lineIndex
|
||||||
|
) throws IOException {
|
||||||
|
String[] fields = data.split(":", -1);
|
||||||
|
if (fields.length != STATUS_RECORD_FIELD_COUNT) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"expected exactly " + STATUS_RECORD_FIELD_COUNT
|
||||||
|
+ " colon-separated fields",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Instant samplingInstant = clock.instant();
|
LocalDateTime localDateTime = LocalDateTime.parse(
|
||||||
|
fields[0],
|
||||||
|
STATUS_TIMESTAMP_FORMATTER
|
||||||
|
);
|
||||||
|
Instant timestamp = localDateTime.toInstant(ZoneOffset.UTC);
|
||||||
|
int recordType = Integer.parseInt(fields[1]);
|
||||||
|
if (recordType != EVELYN_IOU_TOKEN_PRICE_INDEX_RECORD_TYPE) {
|
||||||
|
throw new UnknownStatusRecordTypeException(recordType);
|
||||||
|
}
|
||||||
|
BigDecimal evelynPriceIndex = new BigDecimal(fields[2]);
|
||||||
|
return new EvelynStatusIndexPoint(
|
||||||
|
timestamp,
|
||||||
|
evelynPriceIndex,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO
|
||||||
|
);
|
||||||
|
} catch (UnknownStatusRecordTypeException exception) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"unknown status record type " + exception.recordType,
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw malformedStatusHistory(
|
||||||
|
lineIndex,
|
||||||
|
rawLine,
|
||||||
|
"invalid status record",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IOException malformedStatusHistory(
|
||||||
|
int lineIndex,
|
||||||
|
String rawLine,
|
||||||
|
String reason,
|
||||||
|
Exception cause
|
||||||
|
) {
|
||||||
|
return new IOException(
|
||||||
|
"Malformed Evelyn status history in " + statusHistoryPath
|
||||||
|
+ " at line " + (lineIndex + 1) + ": " + rawLine
|
||||||
|
+ " (" + reason + ")",
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sampleStatusIndexSafely() {
|
||||||
|
Instant samplingInstant = null;
|
||||||
|
try {
|
||||||
|
samplingInstant = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
|
||||||
PriceObservation observation = tickerService.getLatestPrice(eveUsdtTradingPair);
|
PriceObservation observation = tickerService.getLatestPrice(eveUsdtTradingPair);
|
||||||
BigDecimal actualPrice = observation.price().price();
|
BigDecimal actualPrice = observation.price().price();
|
||||||
BigDecimal evelynPriceIndex = EvelynPriceIndexCalculator.calculateIndex(
|
BigDecimal evelynPriceIndex = EvelynPriceIndexCalculator.calculateIndex(
|
||||||
@@ -172,29 +592,175 @@ public class EvelynImpl implements Evelyn {
|
|||||||
BigDecimal.ZERO,
|
BigDecimal.ZERO,
|
||||||
BigDecimal.ZERO
|
BigDecimal.ZERO
|
||||||
);
|
);
|
||||||
if (!isStatusIndexSamplingActive()) {
|
persistAndPublish(point);
|
||||||
return;
|
} catch (IOException exception) {
|
||||||
}
|
log.error(
|
||||||
synchronized (statusIndexHistory) {
|
"Could not persist Evelyn Price Index: instance={}, path={}, samplingInstant={}",
|
||||||
statusIndexHistory.add(point);
|
instanceName,
|
||||||
}
|
statusHistoryPath,
|
||||||
|
samplingInstant,
|
||||||
|
exception
|
||||||
|
);
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
log.warn(
|
log.warn(
|
||||||
"Could not sample Evelyn Price Index: tradingPair={}",
|
"Could not sample Evelyn Price Index: instance={}, path={}, tradingPair={}, samplingInstant={}",
|
||||||
|
instanceName,
|
||||||
|
statusHistoryPath,
|
||||||
eveUsdtTradingPair,
|
eveUsdtTradingPair,
|
||||||
|
samplingInstant,
|
||||||
exception
|
exception
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void persistAndPublish(EvelynStatusIndexPoint point) throws IOException {
|
||||||
|
synchronized (statusIndexHistory) {
|
||||||
|
if (!isStatusIndexSamplingActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastPersistedTimestamp != null
|
||||||
|
&& point.timestamp().isBefore(lastPersistedTimestamp)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Sample timestamp " + point.timestamp()
|
||||||
|
+ " is earlier than last persisted timestamp "
|
||||||
|
+ lastPersistedTimestamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendStatusRecord(point);
|
||||||
|
lastPersistedTimestamp = point.timestamp();
|
||||||
|
statusIndexHistory.add(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendStatusRecord(EvelynStatusIndexPoint point) throws IOException {
|
||||||
|
String encodedRecord = STATUS_TIMESTAMP_FORMATTER.format(
|
||||||
|
LocalDateTime.ofInstant(point.timestamp(), ZoneOffset.UTC)
|
||||||
|
) + ":" + EVELYN_IOU_TOKEN_PRICE_INDEX_RECORD_TYPE
|
||||||
|
+ ":" + point.evelynPriceIndex().toPlainString() + "\n";
|
||||||
|
|
||||||
|
try (FileChannel channel = FileChannel.open(
|
||||||
|
statusHistoryPath,
|
||||||
|
StandardOpenOption.READ,
|
||||||
|
StandardOpenOption.WRITE
|
||||||
|
)) {
|
||||||
|
long size = channel.size();
|
||||||
|
boolean needsLineSeparator = size > 0
|
||||||
|
&& !endsWithLineSeparator(channel, size);
|
||||||
|
channel.position(size);
|
||||||
|
if (needsLineSeparator) {
|
||||||
|
writeFully(channel, ByteBuffer.wrap(new byte[] {'\n'}));
|
||||||
|
}
|
||||||
|
writeFully(
|
||||||
|
channel,
|
||||||
|
ByteBuffer.wrap(encodedRecord.getBytes(StandardCharsets.UTF_8))
|
||||||
|
);
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean endsWithLineSeparator(FileChannel channel, long size)
|
||||||
|
throws IOException {
|
||||||
|
ByteBuffer lastByte = ByteBuffer.allocate(1);
|
||||||
|
channel.position(size - 1);
|
||||||
|
if (channel.read(lastByte) != 1) {
|
||||||
|
throw new IOException(
|
||||||
|
"Could not inspect final byte of Evelyn status history: "
|
||||||
|
+ statusHistoryPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
byte value = lastByte.array()[0];
|
||||||
|
return value == '\n' || value == '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeFully(FileChannel channel, ByteBuffer buffer)
|
||||||
|
throws IOException {
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
channel.write(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String removeComment(String line) {
|
||||||
|
int commentStart = line.indexOf('#');
|
||||||
|
return commentStart < 0 ? line : line.substring(0, commentStart);
|
||||||
|
}
|
||||||
|
|
||||||
private synchronized boolean isStatusIndexSamplingActive() {
|
private synchronized boolean isStatusIndexSamplingActive() {
|
||||||
return statusIndexScheduler != null && !stoppingStatusIndexSampling;
|
return statusIndexScheduler != null
|
||||||
|
&& lifecycleState == LifecycleState.STARTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String validateInstanceName(String instanceName) {
|
||||||
|
Objects.requireNonNull(instanceName, "instanceName");
|
||||||
|
String normalizedName = Normalizer.normalize(instanceName, Normalizer.Form.NFC);
|
||||||
|
if (normalizedName.codePoints().allMatch(EvelynImpl::isWhitespace)
|
||||||
|
|| hasWhitespaceAtBoundary(normalizedName)
|
||||||
|
|| normalizedName.equals(".")
|
||||||
|
|| normalizedName.equals("..")
|
||||||
|
|| normalizedName.endsWith(".")
|
||||||
|
|| normalizedName.codePoints().anyMatch(Character::isISOControl)
|
||||||
|
|| normalizedName.codePoints().anyMatch(
|
||||||
|
codePoint -> PORTABLE_FILENAME_INVALID_CHARACTERS
|
||||||
|
.indexOf(codePoint) >= 0
|
||||||
|
)
|
||||||
|
|| isWindowsReservedFilename(normalizedName)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unsafe Evelyn instance name: " + normalizedName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalizedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasWhitespaceAtBoundary(String value) {
|
||||||
|
return isWhitespace(value.codePointAt(0))
|
||||||
|
|| isWhitespace(value.codePointBefore(value.length()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWhitespace(int codePoint) {
|
||||||
|
return Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWindowsReservedFilename(String value) {
|
||||||
|
String upperName = value.toUpperCase(Locale.ROOT);
|
||||||
|
int extensionSeparator = upperName.indexOf('.');
|
||||||
|
String baseName = extensionSeparator < 0
|
||||||
|
? upperName
|
||||||
|
: upperName.substring(0, extensionSeparator);
|
||||||
|
if (baseName.equals("CON")
|
||||||
|
|| baseName.equals("PRN")
|
||||||
|
|| baseName.equals("AUX")
|
||||||
|
|| baseName.equals("NUL")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (baseName.length() == 4) {
|
||||||
|
String prefix = baseName.substring(0, 3);
|
||||||
|
char suffix = baseName.charAt(3);
|
||||||
|
return (prefix.equals("COM") || prefix.equals("LPT"))
|
||||||
|
&& suffix >= '1' && suffix <= '9';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(EvelynImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(EvelynImpl.class);
|
||||||
|
private static final Path DATA_ROOT = Path.of("data", "evelyn");
|
||||||
|
private static final String STATUS_HISTORY_FILENAME = "status.log";
|
||||||
|
private static final String FORMAT_VERSION_KEY = "FORMAT_VERSION";
|
||||||
|
private static final int SUPPORTED_STATUS_HISTORY_FORMAT_VERSION = 1;
|
||||||
|
private static final int EVELYN_IOU_TOKEN_PRICE_INDEX_RECORD_TYPE = 1;
|
||||||
|
private static final int STATUS_RECORD_FIELD_COUNT = 3;
|
||||||
private static final long SAMPLE_DELAY_MINUTES = 1;
|
private static final long SAMPLE_DELAY_MINUTES = 1;
|
||||||
private static final long TERMINATION_TIMEOUT_SECONDS = 10;
|
private static final long TERMINATION_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final String PORTABLE_FILENAME_INVALID_CHARACTERS = "<>:\"/\\|?*";
|
||||||
|
private static final DateTimeFormatter STATUS_TIMESTAMP_FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("uuuuMMddHHmmssSSS'Z'")
|
||||||
|
.withResolverStyle(ResolverStyle.STRICT);
|
||||||
|
private static final Set<String> RESERVED_INSTANCE_NAMES =
|
||||||
|
ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private final String instanceName;
|
||||||
|
private final String reservationKey;
|
||||||
|
private final Path instancePath;
|
||||||
|
private final Path statusHistoryPath;
|
||||||
private final TickerService tickerService;
|
private final TickerService tickerService;
|
||||||
private final TradingPair eveUsdtTradingPair;
|
private final TradingPair eveUsdtTradingPair;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
@@ -203,7 +769,33 @@ public class EvelynImpl implements Evelyn {
|
|||||||
private final List<EvelynStatusIndexPoint> statusIndexHistory = new ArrayList<>();
|
private final List<EvelynStatusIndexPoint> statusIndexHistory = new ArrayList<>();
|
||||||
|
|
||||||
private ScheduledExecutorService statusIndexScheduler;
|
private ScheduledExecutorService statusIndexScheduler;
|
||||||
private boolean stoppingStatusIndexSampling;
|
private Instant lastPersistedTimestamp;
|
||||||
|
private LifecycleState lifecycleState = LifecycleState.STOPPED;
|
||||||
|
private boolean closeRequested;
|
||||||
|
|
||||||
|
private enum LifecycleState {
|
||||||
|
STOPPED,
|
||||||
|
STARTING,
|
||||||
|
STARTED,
|
||||||
|
STOPPING,
|
||||||
|
TERMINATION_FAILED,
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
private record LoadedHistory(
|
||||||
|
List<EvelynStatusIndexPoint> points,
|
||||||
|
Instant lastTimestamp
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class UnknownStatusRecordTypeException
|
||||||
|
extends RuntimeException {
|
||||||
|
private UnknownStatusRecordTypeException(int recordType) {
|
||||||
|
this.recordType = recordType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final int recordType;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
private SPLTokenHolding getSPLHolding(ΩSolanaAddressΩ ownerAddress, ΩSPLMintAddressΩ splMintAddress) throws Exception {
|
private SPLTokenHolding getSPLHolding(ΩSolanaAddressΩ ownerAddress, ΩSPLMintAddressΩ splMintAddress) throws Exception {
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ public class NenjimHubImpl implements NenjimHub {
|
|||||||
//startJupiterPerpsAlarm(cis);
|
//startJupiterPerpsAlarm(cis);
|
||||||
|
|
||||||
//TradingPair eveUsdt = createEVEUSDTTradingPair(cis);
|
//TradingPair eveUsdt = createEVEUSDTTradingPair(cis);
|
||||||
//Evelyn evelynProd = new EvelynImpl(tickerService, eveUsdt);
|
//Evelyn evelynProd = new EvelynImpl("Production", tickerService, eveUsdt);
|
||||||
//Evelyn evelynTest = new EvelynImpl(tickerService, eveUsdt);
|
//Evelyn evelynTest = new EvelynImpl("Test", tickerService, eveUsdt);
|
||||||
//evelynProd.start();
|
//evelynProd.start();
|
||||||
//evelynTest.start();
|
//evelynTest.start();
|
||||||
//startEvelynMissionControl(evelynProd, evelynTest);
|
//startEvelynMissionControl(evelynProd, evelynTest);
|
||||||
|
|||||||
Reference in New Issue
Block a user