- Configure AWS SDK with 500 max sockets (up from default 50) - Add intelligent backpressure with pending operation tracking - Implement dynamic batch sizing based on memory pressure - Auto-reduce operations when heap usage exceeds 80% - Gradually recover throughput when system stabilizes - Track and respond to consecutive error patterns - Fix S3 mock to not add ID to metadata objects - Add backpressure to metadata save operations - All changes are transparent - no configuration required
574 lines
No EOL
18 KiB
TypeScript
574 lines
No EOL
18 KiB
TypeScript
/**
|
|
* S3 Compatible Storage Mock for Testing
|
|
*
|
|
* This module provides a mock implementation of the AWS S3 client
|
|
* for testing S3-based storage in a Node.js environment without
|
|
* requiring actual S3 credentials.
|
|
*/
|
|
|
|
import { vi } from 'vitest'
|
|
|
|
// In-memory storage to simulate S3 bucket
|
|
interface S3MockObject {
|
|
key: string
|
|
body: string
|
|
metadata?: Record<string, string>
|
|
lastModified: Date
|
|
contentLength: number
|
|
contentType?: string
|
|
}
|
|
|
|
interface S3MockBucket {
|
|
name: string
|
|
objects: Map<string, S3MockObject>
|
|
}
|
|
|
|
// Mock S3 storage - use a global variable to ensure persistence between operations
|
|
// This is important because the mock client is recreated for each command
|
|
const mockS3Storage = new Map<string, S3MockBucket>()
|
|
|
|
/**
|
|
* Create a mock S3 client
|
|
*
|
|
* This function creates a mock S3 client that simulates the behavior of the AWS S3 client.
|
|
* It's important that all operations use the same instance of mockS3Storage to ensure
|
|
* that objects are correctly persisted between operations.
|
|
*/
|
|
export function createMockS3Client() {
|
|
// Log the current state of the mock storage
|
|
console.log(`[MOCK S3] Creating mock S3 client with current storage state:`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log('[MOCK S3] Objects in bucket:')
|
|
for (const key of bucket.objects.keys()) {
|
|
console.log(`[MOCK S3] - ${key}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
send: vi.fn().mockImplementation((command) => {
|
|
// Log the command for debugging
|
|
console.log(`[MOCK S3] Received S3 command: ${command.constructor.name}`, command.input)
|
|
|
|
// Log the current state of the mock storage before processing the command
|
|
console.log(`[MOCK S3] Current storage state before processing command:`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log('[MOCK S3] Objects in bucket:')
|
|
for (const key of bucket.objects.keys()) {
|
|
console.log(`[MOCK S3] - ${key}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle different command types
|
|
let result
|
|
if (command.constructor.name === 'CreateBucketCommand') {
|
|
result = handleCreateBucket(command)
|
|
} else if (command.constructor.name === 'HeadBucketCommand') {
|
|
result = handleHeadBucket(command)
|
|
} else if (command.constructor.name === 'PutObjectCommand') {
|
|
result = handlePutObject(command)
|
|
} else if (command.constructor.name === 'GetObjectCommand') {
|
|
result = handleGetObject(command)
|
|
} else if (command.constructor.name === 'DeleteObjectCommand') {
|
|
result = handleDeleteObject(command)
|
|
} else if (command.constructor.name === 'ListObjectsV2Command') {
|
|
result = handleListObjectsV2(command)
|
|
} else {
|
|
console.warn(`[MOCK S3] Unhandled S3 command: ${command.constructor.name}`)
|
|
result = Promise.resolve({})
|
|
}
|
|
|
|
// Log the current state of the mock storage after processing the command
|
|
result.then(() => {
|
|
console.log(`[MOCK S3] Storage state after processing command:`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log('[MOCK S3] Objects in bucket:')
|
|
for (const key of bucket.objects.keys()) {
|
|
console.log(`[MOCK S3] - ${key}`)
|
|
}
|
|
}
|
|
}
|
|
}).catch(error => {
|
|
console.error(`[MOCK S3] Error processing command:`, error)
|
|
})
|
|
|
|
return result
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle CreateBucketCommand
|
|
*/
|
|
function handleCreateBucket(command: any) {
|
|
const { Bucket } = command.input
|
|
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
mockS3Storage.set(Bucket, {
|
|
name: Bucket,
|
|
objects: new Map()
|
|
})
|
|
}
|
|
|
|
return Promise.resolve({
|
|
Location: `/${Bucket}`
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle HeadBucketCommand
|
|
*/
|
|
function handleHeadBucket(command: any) {
|
|
const { Bucket } = command.input
|
|
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
|
}
|
|
|
|
return Promise.resolve({})
|
|
}
|
|
|
|
/**
|
|
* Handle PutObjectCommand
|
|
*/
|
|
function handlePutObject(command: any) {
|
|
const { Bucket, Key, Body, Metadata, ContentType } = command.input
|
|
|
|
console.log(`PutObjectCommand for bucket: ${Bucket}, key: ${Key}`)
|
|
|
|
// Create bucket if it doesn't exist
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
console.log(`Creating bucket: ${Bucket}`)
|
|
mockS3Storage.set(Bucket, {
|
|
name: Bucket,
|
|
objects: new Map()
|
|
})
|
|
}
|
|
|
|
const bucket = mockS3Storage.get(Bucket)!
|
|
|
|
let bodyContent: string
|
|
if (typeof Body === 'string') {
|
|
bodyContent = Body
|
|
} else if (Body instanceof Uint8Array || Body instanceof Buffer) {
|
|
bodyContent = new TextDecoder().decode(Body)
|
|
} else if (Body && typeof Body.toString === 'function') {
|
|
bodyContent = Body.toString()
|
|
} else {
|
|
bodyContent = JSON.stringify(Body)
|
|
}
|
|
|
|
// Log the key and body content for debugging
|
|
console.log(`Storing object with key: ${Key}`)
|
|
console.log(`Body content: ${bodyContent.substring(0, 50)}${bodyContent.length > 50 ? '...' : ''}`)
|
|
|
|
// Parse the body content if it's JSON to ensure it's valid
|
|
try {
|
|
if (ContentType === 'application/json') {
|
|
const parsedBody = JSON.parse(bodyContent)
|
|
console.log(`Parsed JSON body:`, parsedBody)
|
|
|
|
// If this is a noun or verb (but NOT metadata), ensure it has an id property
|
|
if ((Key.includes('/nouns/') || Key.includes('/verbs/')) && !Key.includes('/metadata/')) {
|
|
if (!parsedBody.id) {
|
|
console.error(`Warning: Object ${Key} does not have an id property`)
|
|
// Add id property based on the key name
|
|
const id = Key.split('/').pop()?.replace('.json', '') || 'unknown'
|
|
parsedBody.id = id
|
|
console.log(`Added id property: ${id}`)
|
|
bodyContent = JSON.stringify(parsedBody)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error parsing JSON body for ${Key}:`, error)
|
|
// Continue with the original body content
|
|
}
|
|
|
|
// Store the object in the bucket
|
|
bucket.objects.set(Key, {
|
|
key: Key,
|
|
body: bodyContent,
|
|
metadata: Metadata,
|
|
lastModified: new Date(),
|
|
contentLength: bodyContent.length,
|
|
contentType: ContentType
|
|
})
|
|
|
|
// Debug: Log all objects in the bucket after adding the new one
|
|
console.log(`All objects in bucket ${Bucket} after adding ${Key}:`)
|
|
for (const [key, obj] of bucket.objects.entries()) {
|
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
|
}
|
|
|
|
// Return a success response
|
|
const response = {
|
|
ETag: `"${Math.random().toString(36).substring(2, 15)}"`
|
|
}
|
|
|
|
console.log(`PutObjectCommand successful for ${Key}`)
|
|
return Promise.resolve(response)
|
|
}
|
|
|
|
/**
|
|
* Handle GetObjectCommand
|
|
*/
|
|
function handleGetObject(command: any) {
|
|
const { Bucket, Key } = command.input
|
|
|
|
console.log(`GetObjectCommand for bucket: ${Bucket}, key: ${Key}`)
|
|
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
console.log(`Bucket ${Bucket} not found`)
|
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
|
}
|
|
|
|
const bucket = mockS3Storage.get(Bucket)!
|
|
|
|
// Debug: Log all objects in the bucket
|
|
console.log(`All objects in bucket ${Bucket}:`)
|
|
for (const [key, obj] of bucket.objects.entries()) {
|
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
|
}
|
|
|
|
if (!bucket.objects.has(Key)) {
|
|
console.log(`Object ${Key} not found in bucket ${Bucket}`)
|
|
// Return null for non-existent objects instead of rejecting
|
|
return Promise.reject(new Error(`NoSuchKey: The specified key does not exist.`))
|
|
}
|
|
|
|
const object = bucket.objects.get(Key)!
|
|
console.log(`Found object ${Key} in bucket ${Bucket}`)
|
|
console.log(`Object body: ${object.body.substring(0, 50)}${object.body.length > 50 ? '...' : ''}`)
|
|
|
|
// If this is a JSON object, ensure it has the required properties
|
|
let bodyContent = object.body
|
|
if (object.contentType === 'application/json') {
|
|
try {
|
|
const parsedBody = JSON.parse(bodyContent)
|
|
console.log(`Parsed JSON body for ${Key}:`, parsedBody)
|
|
|
|
// If this is a noun or verb (but NOT metadata), ensure it has an id property
|
|
if ((Key.includes('/nouns/') || Key.includes('/verbs/')) && !Key.includes('/metadata/')) {
|
|
if (!parsedBody.id) {
|
|
console.error(`Warning: Object ${Key} does not have an id property`)
|
|
// Add id property based on the key name
|
|
const id = Key.split('/').pop()?.replace('.json', '') || 'unknown'
|
|
parsedBody.id = id
|
|
console.log(`Added id property: ${id}`)
|
|
bodyContent = JSON.stringify(parsedBody)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error parsing JSON body for ${Key}:`, error)
|
|
// Continue with the original body
|
|
}
|
|
}
|
|
|
|
// Create a response object that matches what the S3 SDK would return
|
|
const response = {
|
|
Body: {
|
|
transformToString: () => Promise.resolve(bodyContent),
|
|
transformToByteArray: () => Promise.resolve(new TextEncoder().encode(bodyContent))
|
|
},
|
|
Metadata: object.metadata || {},
|
|
LastModified: object.lastModified,
|
|
ContentLength: bodyContent.length,
|
|
ContentType: object.contentType
|
|
}
|
|
|
|
console.log(`Returning response for ${Key}`)
|
|
return Promise.resolve(response)
|
|
}
|
|
|
|
/**
|
|
* Handle DeleteObjectCommand
|
|
*/
|
|
function handleDeleteObject(command: any) {
|
|
const { Bucket, Key } = command.input
|
|
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
|
}
|
|
|
|
const bucket = mockS3Storage.get(Bucket)!
|
|
|
|
if (!bucket.objects.has(Key)) {
|
|
return Promise.reject(new Error(`Object not found: ${Key}`))
|
|
}
|
|
|
|
bucket.objects.delete(Key)
|
|
|
|
return Promise.resolve({})
|
|
}
|
|
|
|
/**
|
|
* Handle ListObjectsV2Command
|
|
*/
|
|
function handleListObjectsV2(command: any) {
|
|
const { Bucket, Prefix, MaxKeys = 1000, ContinuationToken } = command.input
|
|
|
|
console.log(`ListObjectsV2Command for bucket: ${Bucket}, prefix: ${Prefix || 'none'}`)
|
|
|
|
if (!mockS3Storage.has(Bucket)) {
|
|
console.log(`Bucket ${Bucket} not found, returning empty result`)
|
|
// Return empty result instead of rejecting
|
|
return Promise.resolve({
|
|
Contents: [],
|
|
IsTruncated: false,
|
|
KeyCount: 0
|
|
})
|
|
}
|
|
|
|
const bucket = mockS3Storage.get(Bucket)!
|
|
|
|
// Debug: Log all objects in the bucket
|
|
console.log(`All objects in bucket ${Bucket} before filtering:`)
|
|
for (const [key, obj] of bucket.objects.entries()) {
|
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
|
}
|
|
|
|
// Filter objects by prefix if provided
|
|
console.log(`[MOCK S3] Filtering objects by prefix: "${Prefix || 'none'}"`)
|
|
console.log(`[MOCK S3] All keys in bucket before filtering:`)
|
|
for (const key of bucket.objects.keys()) {
|
|
console.log(`[MOCK S3] - ${key}`)
|
|
}
|
|
|
|
const filteredObjects = Array.from(bucket.objects.values()).filter(obj => {
|
|
if (!Prefix) return true
|
|
const matches = obj.key.startsWith(Prefix)
|
|
console.log(`[MOCK S3] Key: ${obj.key}, Matches prefix "${Prefix}": ${matches}`)
|
|
return matches
|
|
})
|
|
|
|
console.log(`Found ${filteredObjects.length} objects with prefix: ${Prefix || 'none'}`)
|
|
|
|
// Debug: Log filtered objects
|
|
console.log(`Filtered objects:`)
|
|
for (const obj of filteredObjects) {
|
|
console.log(`- ${obj.key}: ${obj.body.substring(0, 30)}...`)
|
|
|
|
// Ensure each object has a valid body
|
|
try {
|
|
if (obj.contentType === 'application/json') {
|
|
const parsedBody = JSON.parse(obj.body)
|
|
console.log(`Parsed JSON body for ${obj.key}:`, parsedBody)
|
|
|
|
// If this is a noun or verb, ensure it has an id property
|
|
if (obj.key.includes('/nouns/') || obj.key.includes('/verbs/')) {
|
|
if (!parsedBody.id) {
|
|
console.error(`Warning: Object ${obj.key} does not have an id property`)
|
|
// Add id property based on the key name
|
|
const id = obj.key.split('/').pop()?.replace('.json', '') || 'unknown'
|
|
parsedBody.id = id
|
|
console.log(`Added id property: ${id}`)
|
|
obj.body = JSON.stringify(parsedBody)
|
|
obj.contentLength = obj.body.length
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error parsing JSON body for ${obj.key}:`, error)
|
|
// Continue with the original body
|
|
}
|
|
}
|
|
|
|
// Handle pagination
|
|
const startIndex = ContinuationToken ? parseInt(ContinuationToken, 10) : 0
|
|
const endIndex = Math.min(startIndex + MaxKeys, filteredObjects.length)
|
|
const objects = filteredObjects.slice(startIndex, endIndex)
|
|
|
|
// Check if there are more objects
|
|
const isTruncated = endIndex < filteredObjects.length
|
|
const nextContinuationToken = isTruncated ? endIndex.toString() : undefined
|
|
|
|
// Map objects to the expected format
|
|
const contents = objects.map(obj => ({
|
|
Key: obj.key,
|
|
LastModified: obj.lastModified,
|
|
Size: obj.contentLength || obj.body.length, // Ensure Size is always set
|
|
ETag: `"${Math.random().toString(36).substring(2, 15)}"`
|
|
}))
|
|
|
|
console.log(`Returning ${contents.length} objects in response`)
|
|
|
|
// Debug: Log the contents being returned
|
|
if (contents.length > 0) {
|
|
console.log(`Contents being returned:`)
|
|
for (const obj of contents) {
|
|
console.log(`- ${obj.Key}, Size: ${obj.Size}`)
|
|
}
|
|
}
|
|
|
|
// Always return Contents array, even if empty
|
|
return Promise.resolve({
|
|
Contents: contents,
|
|
IsTruncated: isTruncated,
|
|
NextContinuationToken: nextContinuationToken,
|
|
KeyCount: objects.length
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Setup S3 mock environment
|
|
*/
|
|
export function setupS3Mock() {
|
|
console.log('Setting up S3 mock environment')
|
|
|
|
// Clear the mock S3 storage
|
|
mockS3Storage.clear()
|
|
|
|
// Create mock S3 client with enhanced logging
|
|
const mockS3Client = {
|
|
send: async (command: any) => {
|
|
console.log(`[MOCK S3] Received command: ${command.constructor.name}`)
|
|
console.log(`[MOCK S3] Command input:`, command.input)
|
|
|
|
// Log the current state of the mock storage before processing the command
|
|
console.log(`[MOCK S3] Current storage state before command:`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log(`[MOCK S3] Objects in bucket ${bucketName}:`)
|
|
for (const [key, obj] of bucket.objects.entries()) {
|
|
console.log(`[MOCK S3] - ${key}: ${obj.body.substring(0, 30)}...`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process the command using the original implementation
|
|
const result = await createMockS3Client().send(command)
|
|
|
|
// Log the result and the state of the mock storage after processing the command
|
|
console.log(`[MOCK S3] Command result:`, result)
|
|
console.log(`[MOCK S3] Storage state after command:`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log(`[MOCK S3] Objects in bucket ${bucketName}:`)
|
|
for (const [key, obj] of bucket.objects.entries()) {
|
|
console.log(`[MOCK S3] - ${key}: ${obj.body.substring(0, 30)}...`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Create a test bucket to ensure it exists
|
|
const testBucket = 'test-bucket'
|
|
if (!mockS3Storage.has(testBucket)) {
|
|
console.log(`Creating test bucket: ${testBucket}`)
|
|
mockS3Storage.set(testBucket, {
|
|
name: testBucket,
|
|
objects: new Map()
|
|
})
|
|
}
|
|
|
|
console.log('S3 mock environment setup complete')
|
|
|
|
return {
|
|
mockS3Client,
|
|
mockS3Storage,
|
|
reset: () => {
|
|
console.log('[MOCK S3] Resetting S3 mock storage')
|
|
|
|
// Log the state of the mock storage before reset
|
|
console.log('[MOCK S3] Mock storage before reset:')
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
if (bucket.objects.size > 0) {
|
|
console.log('[MOCK S3] Objects in bucket:')
|
|
for (const key of bucket.objects.keys()) {
|
|
console.log(`[MOCK S3] - ${key}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear the mock S3 storage completely
|
|
mockS3Storage.clear()
|
|
|
|
// Re-create the test bucket with an empty objects map
|
|
console.log(`[MOCK S3] Re-creating test bucket: ${testBucket}`)
|
|
mockS3Storage.set(testBucket, {
|
|
name: testBucket,
|
|
objects: new Map()
|
|
})
|
|
|
|
// Log the state of the mock storage after reset
|
|
console.log(`[MOCK S3] Mock storage after reset: ${mockS3Storage.size} buckets`)
|
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
|
}
|
|
|
|
// Ensure the mock client is using the latest storage state
|
|
console.log('[MOCK S3] Ensuring mock client is using the latest storage state')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup S3 mock environment
|
|
*/
|
|
export function cleanupS3Mock() {
|
|
console.log('Cleaning up S3 mock environment')
|
|
|
|
// Reset mocks
|
|
vi.restoreAllMocks()
|
|
|
|
// Clear the mock S3 storage
|
|
mockS3Storage.clear()
|
|
|
|
console.log('S3 mock environment cleanup complete')
|
|
}
|
|
|
|
/**
|
|
* Create mock S3 command classes
|
|
*/
|
|
export const S3Commands = {
|
|
CreateBucketCommand: class CreateBucketCommand {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
},
|
|
HeadBucketCommand: class HeadBucketCommand {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
},
|
|
PutObjectCommand: class PutObjectCommand {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
},
|
|
GetObjectCommand: class GetObjectCommand {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
},
|
|
DeleteObjectCommand: class DeleteObjectCommand {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
},
|
|
ListObjectsV2Command: class ListObjectsV2Command {
|
|
input: any
|
|
constructor(input: any) {
|
|
this.input = input
|
|
}
|
|
}
|
|
} |