diff --git a/.env.example b/.env.example index 90de5ea03e..c19992b7e2 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,17 @@ MESH_API_KEY=da2-your-api-key # AWS Region (Production) # Example: ap-northeast-1 MESH_AWS_REGION=ap-northeast-1 + +# Data update interval in milliseconds +# Default: 1000 +MESH_DATA_UPDATE_INTERVAL_MS=1000 + +# Event batch interval in milliseconds +# Default: 1000 +MESH_EVENT_BATCH_INTERVAL_MS=1000 + +# Debug logging (comma-separated categories, e.g., scratch-vm:*) +DEBUG= + +# Development server port +PORT=8073 diff --git a/.gitignore b/.gitignore index 76c05bbafb..a24cfe2335 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ npm-* # Load Test /test/load-test/node_modules + +# Environment +.env diff --git a/src/extensions/scratch3_mesh_v2/gql-operations.js b/src/extensions/scratch3_mesh_v2/gql-operations.js index 9a2b25f536..19ee27b244 100644 --- a/src/extensions/scratch3_mesh_v2/gql-operations.js +++ b/src/extensions/scratch3_mesh_v2/gql-operations.js @@ -31,6 +31,7 @@ const CREATE_GROUP = gql` hostId createdAt expiresAt + heartbeatIntervalSeconds } } `; diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index ceda5b9f89..b7295043ad 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -18,7 +18,22 @@ const { LIST_GROUP_STATUSES } = require('./gql-operations'); -const CONNECTION_TIMEOUT = 50 * 60 * 1000; // 50 minutes in milliseconds +/** + * Parses an environment variable as an integer with validation. + * @param {string} envVar - The environment variable value. + * @param {number} defaultValue - The default value if parsing fails or is out of range. + * @param {number} min - Minimum allowed value (inclusive). + * @param {number} max - Maximum allowed value (inclusive). + * @returns {number} The parsed integer or default value. + */ +const parseEnvInt = (envVar, defaultValue, min = 0, max = Infinity) => { + if (!envVar) return defaultValue; + const parsed = parseInt(envVar, 10); + if (isNaN(parsed) || parsed < min || parsed > max) return defaultValue; + return parsed; +}; + +// Mesh v2 configuration parameters /** * GraphQL error types that indicate the connection is no longer valid. @@ -52,20 +67,34 @@ class MeshV2Service { // Last data send promise to track completion of the most recent data transmission this.lastDataSendPromise = Promise.resolve(); + this.hostHeartbeatInterval = 60; // Default 1 min this.memberHeartbeatInterval = 120; // Default 2 min // Data from other nodes: { nodeId: { key: { value: string, timestamp: number } } } this.remoteData = {}; // Rate limiters - this.dataRateLimiter = new RateLimiter(4, 250, { + // Data update interval (default: 1000ms) + const dataInterval = parseEnvInt( + process.env.MESH_DATA_UPDATE_INTERVAL_MS, + 1000, // default + 100, // min: 100ms + 10000 // max: 10 seconds + ); + this.dataRateLimiter = new RateLimiter(4, dataInterval, { enableMerge: true, mergeKeyField: 'key' }); // Event queue for batch sending: { eventName, payload, firedAt } の配列 this.eventQueue = []; - this.eventBatchInterval = 250; + // Event batch interval (default: 1000ms) + this.eventBatchInterval = parseEnvInt( + process.env.MESH_EVENT_BATCH_INTERVAL_MS, + 1000, // default + 100, // min: 100ms + 10000 // max: 10 seconds + ); this.eventBatchTimer = null; // Event queue limits @@ -198,6 +227,9 @@ class MeshV2Service { this.domain = group.domain; // Update domain from server this.expiresAt = group.expiresAt; this.isHost = true; + if (group.heartbeatIntervalSeconds) { + this.hostHeartbeatInterval = group.heartbeatIntervalSeconds; + } this.costTracking.connectionStartTime = Date.now(); this.startSubscriptions(); @@ -656,8 +688,7 @@ class MeshV2Service { if (!this.groupId) return; log.info(`Mesh V2: Starting heartbeat timer (Role: ${this.isHost ? 'Host' : 'Member'})`); - // Use 15s for host, memberHeartbeatInterval for member (default 120s) - const interval = this.isHost ? 15 * 1000 : this.memberHeartbeatInterval * 1000; + const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000; this.heartbeatTimer = setInterval(() => { if (this.isHost) { @@ -692,6 +723,13 @@ class MeshV2Service { }); this.expiresAt = result.data.renewHeartbeat.expiresAt; log.info(`Mesh V2: Heartbeat renewed. Expires at: ${this.expiresAt}`); + if (result.data.renewHeartbeat.heartbeatIntervalSeconds) { + const newInterval = result.data.renewHeartbeat.heartbeatIntervalSeconds; + if (newInterval !== this.hostHeartbeatInterval) { + this.hostHeartbeatInterval = newInterval; + this.startHeartbeat(); // Restart with new interval + } + } this.startConnectionTimer(); return result.data.renewHeartbeat; } catch (error) { @@ -741,13 +779,15 @@ class MeshV2Service { startConnectionTimer () { this.stopConnectionTimer(); - let timeout = CONNECTION_TIMEOUT; - if (this.expiresAt) { - const serverTimeout = new Date(this.expiresAt).getTime() - Date.now(); - if (serverTimeout > 0) { - timeout = serverTimeout; - } + if (!this.expiresAt) return; + + const timeout = new Date(this.expiresAt).getTime() - Date.now(); + if (timeout <= 0) { + log.warn('Mesh V2: Group is already expired'); + this.leaveGroup(); + return; } + const timeoutMinutes = Math.round(timeout / 60000); this.connectionTimer = setTimeout(() => { log.warn(`Mesh V2: Connection timeout (${timeoutMinutes} minutes)`); diff --git a/test/unit/extension_mesh_v2.js b/test/unit/extension_mesh_v2.js index 757b58be04..fb8ca98a9b 100644 --- a/test/unit/extension_mesh_v2.js +++ b/test/unit/extension_mesh_v2.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const URLSearchParams = require('url').URLSearchParams; const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js'); const Variable = require('../../src/engine/variable'); diff --git a/test/unit/extension_mesh_v2_integration.js b/test/unit/extension_mesh_v2_integration.js index 58644d72c2..7cab50b757 100644 --- a/test/unit/extension_mesh_v2_integration.js +++ b/test/unit/extension_mesh_v2_integration.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const URLSearchParams = require('url').URLSearchParams; const MeshBlocks = require('../../src/extensions/scratch3_mesh/index.js'); const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js'); diff --git a/test/unit/extension_mesh_v2_issue66.js b/test/unit/extension_mesh_v2_issue66.js index 66c28ebce7..6b6d7a4a8b 100644 --- a/test/unit/extension_mesh_v2_issue66.js +++ b/test/unit/extension_mesh_v2_issue66.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const URLSearchParams = require('url').URLSearchParams; const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js'); const Variable = require('../../src/engine/variable'); diff --git a/test/unit/extension_mesh_v2_service.js b/test/unit/extension_mesh_v2_service.js index f241d1d74d..364d704c17 100644 --- a/test/unit/extension_mesh_v2_service.js +++ b/test/unit/extension_mesh_v2_service.js @@ -1,5 +1,9 @@ /* eslint-disable require-atomic-updates */ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug logs during tests +minilog.suggest.deny('vm', 'debug'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const log = require('../../src/util/log'); @@ -35,18 +39,22 @@ test('MeshV2Service Cost Tracking', t => { id: 'g1', name: 'G1', domain: 'd1', - expiresAt: '2026-01-01T00:00:00Z' + expiresAt: '2099-01-01T00:00:00Z', + heartbeatIntervalSeconds: 60 }, joinGroup: { id: 'n1', domain: 'd1', - expiresAt: '2026-01-01T00:00:00Z' + expiresAt: '2099-01-01T00:00:00Z', + heartbeatIntervalSeconds: 120 }, renewHeartbeat: { - expiresAt: '2026-01-01T00:00:00Z' + expiresAt: '2099-01-01T00:00:00Z', + heartbeatIntervalSeconds: 60 }, sendMemberHeartbeat: { - expiresAt: '2026-01-01T00:00:00Z' + expiresAt: '2099-01-01T00:00:00Z', + heartbeatIntervalSeconds: 120 } } }), diff --git a/test/unit/mesh_service_v2.js b/test/unit/mesh_service_v2.js index b7acf643ed..66d2979384 100644 --- a/test/unit/mesh_service_v2.js +++ b/test/unit/mesh_service_v2.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const {FIRE_EVENTS} = require('../../src/extensions/scratch3_mesh_v2/gql-operations'); const BlockUtility = require('../../src/engine/block-utility'); diff --git a/test/unit/mesh_service_v2_global_vars.js b/test/unit/mesh_service_v2_global_vars.js index e2207e0458..d1efe7ae60 100644 --- a/test/unit/mesh_service_v2_global_vars.js +++ b/test/unit/mesh_service_v2_global_vars.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const Variable = require('../../src/engine/variable'); @@ -104,7 +109,8 @@ test('MeshV2Service Global Variables', t => { id: 'group1', name: 'groupName', domain: 'domain1', - expiresAt: '2025-12-30T12:00:00Z' + expiresAt: '2099-01-01T00:00:00Z', + heartbeatIntervalSeconds: 60 } } }), diff --git a/test/unit/mesh_service_v2_integration.js b/test/unit/mesh_service_v2_integration.js index 05149cdda2..889a5ff167 100644 --- a/test/unit/mesh_service_v2_integration.js +++ b/test/unit/mesh_service_v2_integration.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const BlockUtility = require('../../src/engine/block-utility'); diff --git a/test/unit/mesh_service_v2_order.js b/test/unit/mesh_service_v2_order.js index de576565d9..1bd5cda405 100644 --- a/test/unit/mesh_service_v2_order.js +++ b/test/unit/mesh_service_v2_order.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const {REPORT_DATA, FIRE_EVENTS} = require('../../src/extensions/scratch3_mesh_v2/gql-operations'); diff --git a/test/unit/mesh_service_v2_subscription.js b/test/unit/mesh_service_v2_subscription.js index 62b6cae459..7428db2ff7 100644 --- a/test/unit/mesh_service_v2_subscription.js +++ b/test/unit/mesh_service_v2_subscription.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const {ON_MESSAGE_IN_GROUP} = require('../../src/extensions/scratch3_mesh_v2/gql-operations'); diff --git a/test/unit/mesh_service_v2_timestamp.js b/test/unit/mesh_service_v2_timestamp.js index 3b0c75fe64..4523b852b9 100644 --- a/test/unit/mesh_service_v2_timestamp.js +++ b/test/unit/mesh_service_v2_timestamp.js @@ -1,4 +1,9 @@ const test = require('tap').test; +const minilog = require('minilog'); +// Suppress debug and info logs during tests +minilog.suggest.deny('vm', 'debug'); +minilog.suggest.deny('vm', 'info'); + const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); const createMockBlocks = () => ({ diff --git a/webpack.config.js b/webpack.config.js index faf0579148..dfc05d5535 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -76,7 +76,9 @@ const webBuilder = new ScratchWebpackConfigBuilder(common) .addPlugin(new webpack.DefinePlugin({ 'process.env.MESH_GRAPHQL_ENDPOINT': JSON.stringify(process.env.MESH_GRAPHQL_ENDPOINT), 'process.env.MESH_API_KEY': JSON.stringify(process.env.MESH_API_KEY), - 'process.env.MESH_AWS_REGION': JSON.stringify(process.env.MESH_AWS_REGION) + 'process.env.MESH_AWS_REGION': JSON.stringify(process.env.MESH_AWS_REGION), + 'process.env.MESH_DATA_UPDATE_INTERVAL_MS': JSON.stringify(process.env.MESH_DATA_UPDATE_INTERVAL_MS), + 'process.env.MESH_EVENT_BATCH_INTERVAL_MS': JSON.stringify(process.env.MESH_EVENT_BATCH_INTERVAL_MS) })); const playgroundBuilder = webBuilder.clone()