Skip to content

Commit fc6266d

Browse files
perf(database): add configurable pool, query timeouts, and transactions (#93)
* perf(database): add configurable pool, query timeouts, and transactions - Use TIMEOUTS config for connection timeout (was hardcoded) - Make connectionLimit configurable via DB_CONNECTION_LIMIT env var - Add query() wrapper with per-query timeout and slow query logging - Add withTransaction() for atomic multi-step operations - Wrap registerUser in transaction to prevent partial writes - Log slow queries (>1s threshold, configurable via DB_SLOW_QUERY_THRESHOLD) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: address PR review feedback - Soften first-user admin comment (not strictly race-safe without FOR UPDATE) - Handle ER_DUP_ENTRY race condition for email uniqueness - Fix misleading comment about mysql2 timeout option - Document DB_CONNECTION_LIMIT, DB_QUEUE_LIMIT, DB_SLOW_QUERY_THRESHOLD in .env.example 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 61a6779 commit fc6266d

File tree

3 files changed

+154
-39
lines changed

3 files changed

+154
-39
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ DB_PASSWORD=secure_password_123 # CHANGE THIS IN PRODUCTION!
9090
DB_NAME=meteo_app # Database name
9191
DB_ROOT_PASSWORD=root_password_456 # MySQL root password - CHANGE THIS!
9292

93+
# Database Pool Settings (optional - defaults are usually fine)
94+
# DB_CONNECTION_LIMIT=10 # Max connections in pool (default: 10)
95+
# DB_QUEUE_LIMIT=0 # Max queued requests when pool exhausted (0 = unlimited)
96+
# DB_SLOW_QUERY_THRESHOLD=1000 # Log queries slower than this (ms, default: 1000)
97+
9398
# ====================================
9499
# SERVER CONFIGURATION
95100
# ====================================

backend/config/database.js

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,116 @@
11
const mysql = require('mysql2/promise');
22
require('dotenv').config();
3+
const TIMEOUTS = require('./timeouts');
4+
5+
/**
6+
* Helper to get environment variable as number with fallback
7+
*/
8+
function getEnvNumber(key, defaultValue) {
9+
const value = process.env[key];
10+
if (value === undefined || value === null || value === '') {
11+
return defaultValue;
12+
}
13+
const parsed = parseInt(value, 10);
14+
return isNaN(parsed) ? defaultValue : parsed;
15+
}
316

4-
// Database configuration
17+
// Database configuration with configurable pool settings and timeouts
518
const dbConfig = {
619
host: process.env.DB_HOST || 'localhost',
720
port: process.env.DB_PORT || 3306,
821
user: process.env.DB_USER || 'root',
922
password: process.env.DB_PASSWORD,
1023
database: process.env.DB_NAME || 'meteo_app',
1124
waitForConnections: true,
12-
connectionLimit: 10,
13-
queueLimit: 0,
14-
timezone: 'Z' // Use UTC
25+
connectionLimit: getEnvNumber('DB_CONNECTION_LIMIT', 10),
26+
queueLimit: getEnvNumber('DB_QUEUE_LIMIT', 0),
27+
timezone: 'Z', // Use UTC
28+
// Connection timeout for establishing new connections
29+
connectTimeout: TIMEOUTS.DATABASE.CONNECTION_TIMEOUT,
1530
};
1631

1732
// Create connection pool
1833
const pool = mysql.createPool(dbConfig);
1934

35+
// Slow query threshold (log queries slower than this)
36+
const SLOW_QUERY_THRESHOLD_MS = getEnvNumber('DB_SLOW_QUERY_THRESHOLD', 1000);
37+
38+
/**
39+
* Execute a query with timeout and slow query logging
40+
* @param {string} sql - SQL query string
41+
* @param {Array} params - Query parameters
42+
* @param {object} options - Optional settings
43+
* @param {number} options.timeout - Query timeout in ms (default: TIMEOUTS.DATABASE.QUERY_TIMEOUT)
44+
* @param {boolean} options.isComplex - Use complex query timeout (default: false)
45+
* @returns {Promise<Array>} Query results
46+
*/
47+
async function query(sql, params = [], options = {}) {
48+
const timeout = options.timeout ||
49+
(options.isComplex ? TIMEOUTS.DATABASE.COMPLEX_QUERY_TIMEOUT : TIMEOUTS.DATABASE.QUERY_TIMEOUT);
50+
51+
const startTime = Date.now();
52+
53+
try {
54+
// mysql2 supports per-query timeout via the 'timeout' option in query config
55+
const result = await pool.query({ sql, timeout }, params);
56+
57+
const duration = Date.now() - startTime;
58+
if (duration > SLOW_QUERY_THRESHOLD_MS) {
59+
console.warn(`⚠️ Slow query (${duration}ms): ${sql.substring(0, 100)}...`);
60+
}
61+
62+
return result;
63+
} catch (error) {
64+
const duration = Date.now() - startTime;
65+
if (error.code === 'PROTOCOL_SEQUENCE_TIMEOUT' || error.message?.includes('timeout')) {
66+
console.error(`❌ Query timeout after ${duration}ms: ${sql.substring(0, 100)}...`);
67+
}
68+
throw error;
69+
}
70+
}
71+
72+
/**
73+
* Execute multiple queries in a transaction
74+
* Automatically rolls back on error
75+
* @param {Function} callback - Async function receiving connection object
76+
* @returns {Promise<any>} Result from callback
77+
*
78+
* @example
79+
* await withTransaction(async (conn) => {
80+
* await conn.query('INSERT INTO users ...', [data]);
81+
* await conn.query('INSERT INTO preferences ...', [prefs]);
82+
* });
83+
*/
84+
async function withTransaction(callback) {
85+
const connection = await pool.getConnection();
86+
87+
try {
88+
await connection.beginTransaction();
89+
90+
// Create a query wrapper that respects timeouts
91+
const wrappedConnection = {
92+
query: async (sql, params = [], options = {}) => {
93+
const timeout = options.timeout || TIMEOUTS.DATABASE.QUERY_TIMEOUT;
94+
return connection.query({ sql, timeout }, params);
95+
},
96+
execute: async (sql, params = [], options = {}) => {
97+
const timeout = options.timeout || TIMEOUTS.DATABASE.QUERY_TIMEOUT;
98+
return connection.execute({ sql, timeout }, params);
99+
}
100+
};
101+
102+
const result = await callback(wrappedConnection);
103+
104+
await connection.commit();
105+
return result;
106+
} catch (error) {
107+
await connection.rollback();
108+
throw error;
109+
} finally {
110+
connection.release();
111+
}
112+
}
113+
20114
// Test database connection
21115
async function testConnection() {
22116
try {
@@ -97,6 +191,8 @@ async function seedDatabase() {
97191

98192
module.exports = {
99193
pool,
194+
query,
195+
withTransaction,
100196
testConnection,
101197
initializeDatabase,
102198
seedDatabase

backend/services/authService.js

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const bcrypt = require('bcryptjs');
22
const jwt = require('jsonwebtoken');
3-
const { pool } = require('../config/database');
3+
const { pool, withTransaction } = require('../config/database');
44
const { createError, ERROR_CODES } = require('../utils/errorCodes');
55

66
/**
@@ -15,57 +15,71 @@ const SALT_ROUNDS = 10;
1515

1616
/**
1717
* Register a new user
18+
* Uses a transaction to ensure user and preferences are created atomically.
19+
* Handles race conditions via unique constraint on email (ER_DUP_ENTRY).
1820
*/
1921
async function registerUser(email, password, name) {
20-
try {
21-
// Check if user already exists
22-
const [existingUsers] = await pool.query(
23-
'SELECT id FROM users WHERE email = ?',
24-
[email]
25-
);
26-
27-
if (existingUsers.length > 0) {
28-
throw createError(ERROR_CODES.EMAIL_ALREADY_EXISTS, 'Email already registered');
29-
}
30-
31-
// Check if this is the first user (will become admin)
32-
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users');
33-
const isFirstUser = userCount[0].count === 0;
22+
// Check if user already exists (outside transaction for fast rejection)
23+
const [existingUsers] = await pool.query(
24+
'SELECT id FROM users WHERE email = ?',
25+
[email]
26+
);
3427

35-
// Hash password
36-
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
28+
if (existingUsers.length > 0) {
29+
throw createError(ERROR_CODES.EMAIL_ALREADY_EXISTS, 'Email already registered');
30+
}
3731

38-
// Create user (first user is automatically admin)
39-
const [result] = await pool.query(
40-
'INSERT INTO users (email, password_hash, name, is_admin) VALUES (?, ?, ?, ?)',
41-
[email, passwordHash, name, isFirstUser]
42-
);
32+
// Hash password before transaction (CPU-intensive, don't hold connection)
33+
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
4334

44-
const userId = result.insertId;
35+
try {
36+
// Use transaction to ensure atomicity of user + preferences creation
37+
const result = await withTransaction(async (conn) => {
38+
// Check if this is the first user (will become admin)
39+
// Note: This is a best-effort check. In rare race conditions with concurrent
40+
// first registrations, multiple users could become admin. For stricter control,
41+
// use SELECT ... FOR UPDATE or a dedicated admin setup flow.
42+
const [userCount] = await conn.query('SELECT COUNT(*) as count FROM users');
43+
const isFirstUser = userCount[0].count === 0;
44+
45+
// Create user (first user is automatically admin)
46+
const [insertResult] = await conn.query(
47+
'INSERT INTO users (email, password_hash, name, is_admin) VALUES (?, ?, ?, ?)',
48+
[email, passwordHash, name, isFirstUser]
49+
);
50+
51+
const userId = insertResult.insertId;
52+
53+
if (isFirstUser) {
54+
console.log(`🔧 First user registered as admin: ${email}`);
55+
}
4556

46-
if (isFirstUser) {
47-
console.log(`🔧 First user registered as admin: ${email}`);
48-
}
57+
// Create default preferences
58+
await conn.query(
59+
'INSERT INTO user_preferences (user_id) VALUES (?)',
60+
[userId]
61+
);
4962

50-
// Create default preferences
51-
await pool.query(
52-
'INSERT INTO user_preferences (user_id) VALUES (?)',
53-
[userId]
54-
);
63+
return { userId, isFirstUser };
64+
});
5565

56-
// Generate tokens (include admin status)
57-
const tokens = generateTokens(userId, email, isFirstUser);
66+
// Generate tokens (include admin status) - outside transaction
67+
const tokens = generateTokens(result.userId, email, result.isFirstUser);
5868

5969
return {
6070
user: {
61-
id: userId,
71+
id: result.userId,
6272
email,
6373
name,
64-
isAdmin: isFirstUser
74+
isAdmin: result.isFirstUser
6575
},
6676
...tokens
6777
};
6878
} catch (error) {
79+
// Handle race condition where email was taken between pre-check and insert
80+
if (error.code === 'ER_DUP_ENTRY' && error.message?.includes('email')) {
81+
throw createError(ERROR_CODES.EMAIL_ALREADY_EXISTS, 'Email already registered');
82+
}
6983
console.error('Registration error:', error);
7084
throw error;
7185
}

0 commit comments

Comments
 (0)