mongoose-storage-usage
v1.7.1
Published
Theo dõi và thống kê dung lượng MongoDB đã sử dụng theo từng tenant (id_app) cho các model Mongoose trong hệ thống multi-tenant, hỗ trợ cả file đính kèm và cập nhật realtime qua plugin.
Maintainers
Readme
mongoose-storage-usage
Theo dõi & thống kê dung lượng MongoDB đã sử dụng theo từng tenant (id_app)
cho các model Mongoose trong hệ thống multi-tenant — hỗ trợ:
- 🔍 Quét toàn bộ collection có field
id_app, tính dung lượng thật (BSON size) theo từng tenant. - 📎 Cộng thêm dung lượng file đính kèm (field metadata dạng object/mảng có
size, ví dụ upload qua multer). - ⚡ Plugin realtime: tự động
$incdung lượng ngay khi document được tạo/sửa/xoá, không cần đợi job quét. - ⏰ Cron job quét định kỳ để đối soát (reconcile) số liệu.
- 🌐 Express router có sẵn (tuỳ chọn) để expose API.
Cài đặt
npm install mongoose-storage-usagemongoose là peerDependency (bạn tự cài trong dự án của mình, thư viện không
bundle mongoose riêng để tránh version conflict):
npm install mongooseNếu muốn dùng job quét định kỳ (registerCronJob), cài thêm:
npm install node-cronNếu muốn dùng Express router có sẵn (createExpressRouter), cài thêm:
npm install expressYêu cầu MongoDB server >= 4.4 (dùng operator $bsonSize trong pipeline quét).
Hỗ trợ mongoose >= 5.9.0; từ mongoose >= 6.0.0 số liệu dung lượng chính xác
tuyệt đối, còn mongoose 5.x dùng ước lượng (xem phần "Xử lý sự cố cài đặt").
Bắt đầu nhanh
const mongoose = require('mongoose');
const { createStorageUsageTracker } = require('mongoose-storage-usage');
await mongoose.connect('mongodb://localhost:27017/my_db');
// 1. Khởi tạo tracker 1 lần khi start app
const storageUsage = createStorageUsageTracker(mongoose, {
fileFieldsConfig: {
File: ['file'], // model "File" có field "file" chứa {size, ...}
},
});
// 2. Gắn plugin vào schema để tự động theo dõi realtime
const FileSchema = new mongoose.Schema({
id_app: { type: String, required: true, index: true },
file: {
fieldname: String,
originalname: String,
name: String,
extension: String,
size: Number,
truncated: Boolean,
buffer: Buffer,
},
});
FileSchema.plugin(storageUsage.plugin, { fileFields: ['file'] });
mongoose.model('File', FileSchema);
// 3. (Tuỳ chọn) chạy job quét định kỳ để đối soát
storageUsage.registerCronJob('0 2 * * *');
// 4. Truy vấn
const usage = await storageUsage.getTotalUsageByApp('company_123');Xem ví dụ đầy đủ với Express tại examples/basic-usage.js.
Tích hợp với model quản lý tenant (App) để dễ kiểm soát hơn
Nếu bạn có sẵn 1 model quản lý danh sách tenant, ví dụ:
const AppSchema = new mongoose.Schema({
name: String,
address: String,
expire_date: Date,
trang_thai: { type: String, enum: ['0', '1'] }, // "0": Dùng thử, "1": Chính thức
});
const App = mongoose.model('App', AppSchema);...và id_app ở các collection khác chính là App._id, truyền model này vào
appModel khi tạo tracker:
const storageUsage = createStorageUsageTracker(mongoose, {
autoApply: true,
appModel: App, // <-- chỉ cần dòng này
fileFieldsConfig: { File: ['file'] },
});Khi đó, getUsageRanking(), getTotalUsageByApp() và dashboard sẽ tự động:
- Hiển thị tên công ty thay vì chỉ hiển thị
id_app(chuỗi ObjectId khó đọc). - Hiển thị trạng thái dạng badge: "Dùng thử" / "Chính thức".
- Hiển thị ngày hết hạn, tự tô màu vàng khi còn ≤7 ngày, đỏ khi đã hết hạn.
- Cho phép tìm kiếm theo tên trong ô search của dashboard (thay vì chỉ tìm
được theo
id_app). - Hiển thị cả những tenant chưa có dữ liệu dung lượng nào (mới tạo, chưa upload gì) với dung lượng 0 — giúp nhìn thấy toàn cảnh tất cả tenant, không chỉ những tenant đã có dữ liệu.
Tên field không khớp mặc định?
Mặc định thư viện đọc đúng field name, address, expire_date, trang_thai.
Nếu model của bạn đặt tên khác, khai báo lại qua appFields:
const storageUsage = createStorageUsageTracker(mongoose, {
appModel: App,
appFields: {
name: 'company_name', // field thực tế trên schema của bạn
address: 'company_address',
expireDate: 'expiredAt',
status: 'status',
},
appStatusLabels: {
'0': 'Dùng thử',
'1': 'Chính thức',
// có thể thêm giá trị khác, vd '2': 'Tạm khoá'
},
});Dùng trực tiếp trong code (không qua dashboard)
// Tìm app theo tên
const results = await storageUsage.searchApps('acme');
// Lấy toàn bộ danh sách app (kể cả chưa có dữ liệu dung lượng)
const allApps = await storageUsage.listAllApps();
// Ranking kèm cả app chưa dùng gì
const ranking = await storageUsage.getUsageRanking(100, { includeZeroUsageApps: true });Không truyền appModel thì mọi thứ vẫn hoạt động như cũ (chỉ hiển thị id_app),
không cần thay đổi gì thêm ở phần còn lại của code.
Tối ưu cho import/batch lớn (hàng ngàn document cùng lúc)
Khi insertMany()/updateMany()/deleteMany() xử lý hàng ngàn document cùng lúc (import
dữ liệu, đồng bộ hàng loạt...), plugin áp dụng 3 cơ chế tối ưu để không làm chậm hoặc treo
server, đồng thời giảm tối đa số lần ghi DB:
1. Ghi dồn (debounce + batch write) — mặc định luôn bật
Thay vì ghi DB ngay mỗi khi có thay đổi, mọi delta được cộng dồn trong bộ nhớ và gộp lại
thành 1 lần bulkWrite() duy nhất sau mỗi 300ms (mặc định). Gọi insertMany() 20 lần liên
tiếp (import theo từng chunk) → DB chỉ nhận đúng 1–2 lần ghi thay vì 20 lần.
const storageUsage = createStorageUsageTracker(mongoose, {
bulkOptions: {
flushIntervalMs: 300, // khoảng thời gian gộp trước khi ghi DB
maxQueueSize: 5000, // van an toàn: ép flush nếu số tenant/model đang chờ quá nhiều
},
});⚠️ Quan trọng khi shutdown: vì delta được ghi dồn (không ghi ngay), nếu process bị tắt đột
ngột TRƯỚC khi cửa sổ debounce trigger, phần delta đang chờ có thể bị mất. Package đã tự động
flush khi nhận SIGTERM/SIGINT/beforeExit, nhưng nếu bạn tự quản lý graceful shutdown, gọi
thêm cho chắc:
await storageUsage.flushPendingWrites(); // ghi ngay mọi delta đang chờ, không đợi debounce2. Chia nhỏ + nhường event loop (chunk + yield) — mặc định luôn bật
Việc tính kích thước cho hàng ngàn document trong 1 vòng lặp đồng bộ có thể chiếm dụng event
loop liên tục, khiến các request khác trên cùng process bị "đứng khựng" (đây là nguyên nhân
"gây sập server" khi import dữ liệu lớn). Plugin tự động chia batch thành từng khối (mặc định
500 document/khối) và nhường lại event loop giữa các khối bằng setImmediate().
bulkOptions: { chunkSize: 500 } // giảm xuống nếu server phải phản hồi cực nhanh, tăng lên nếu ưu tiên tốc độ import3. Ước lượng bằng lấy mẫu cho batch cực lớn — mặc định bật từ 2000 document
Với insertMany() từ 2000 document trở lên (mặc định), package không tính kích thước
từng document mà lấy mẫu (mặc định 200 document, trải đều toàn mảng) rồi ước lượng trung
bình nhân với tổng số lượng. Vì các document trong 1 lần import thường có cấu trúc khá đồng
nhất (cùng nguồn dữ liệu, cùng field), sai số thường không đáng kể, trong khi tốc độ nhanh hơn
hàng chục đến hàng trăm lần.
bulkOptions: {
sampleThreshold: 2000, // >= ngưỡng này thì chuyển sang lấy mẫu thay vì tính hết
sampleSize: 200, // số document lấy mẫu
}Nếu bạn cần độ chính xác tuyệt đối hơn tốc độ (batch không quá lớn hoặc cần số liệu billing
chính xác), tăng sampleThreshold lên rất cao (vd Infinity) để luôn tính chính xác từng
document — khi đó vẫn được hưởng lợi từ cơ chế chunk + yield ở mục 2.
4. (Tuỳ chọn nâng cao) Offload sang worker_threads
Với batch cực lớn (chục ngàn document trở lên) xảy ra thường xuyên, có thể bật
useWorkerThread để việc tính kích thước chạy hẳn trong 1 thread riêng — event loop chính gần
như không bị ảnh hưởng gì:
bulkOptions: {
useWorkerThread: true,
workerThreshold: 2000, // chỉ dùng worker khi batch >= ngưỡng này
workerPoolSize: 2, // số worker thread tối đa
}Đánh đổi cần biết: worker thread chạy trong tiến trình con, không truy cập được
mongoose.mongo.BSON của main thread (mỗi thread có module registry riêng), nên bên trong
worker package luôn dùng ước lượng qua JSON.stringify thay vì tính BSON chính xác — cùng
cơ chế fallback dùng khi chạy trên mongoose < 6 (xem phần "Xử lý sự cố cài đặt"). Việc gửi dữ
liệu qua ranh giới thread (postMessage) cũng có chi phí serialize riêng, nên useWorkerThread
không phải lúc nào cũng nhanh hơn — với hầu hết trường hợp "vài ngàn document", cơ chế
sampling ở mục 3 đã đủ nhanh mà không cần thêm độ phức tạp của worker. Chỉ bật khi đã đo đạc
thực tế thấy cần thiết.
Khuyến nghị cấu hình theo quy mô
| Quy mô import | Cấu hình khuyến nghị |
|---|---|
| Vài chục – vài trăm document | Dùng mặc định, không cần chỉnh gì |
| Vài ngàn document (< sampleThreshold) | Dùng mặc định — chunk + yield đã đủ mượt |
| Chục ngàn document, thỉnh thoảng | Dùng mặc định (sampling tự kích hoạt) |
| Chục ngàn document, xảy ra liên tục/nhiều luồng song song | Cân nhắc useWorkerThread: true |
| Cần số liệu chính xác tuyệt đối, chấp nhận chậm hơn | Set sampleThreshold: Infinity |
Áp dụng plugin cho toàn bộ model (không cần khai báo từng model)
Có 3 cách, tuỳ vào cấu trúc project:
Cách 1 — autoApply: true (đơn giản nhất, khuyến nghị)
Truyền autoApply: true khi tạo tracker. Mọi schema định nghĩa sau dòng này
sẽ tự động được gắn plugin, kể cả khi bạn không truyền fileFields — dung lượng
file đính kèm được tra theo fileFieldsConfig (theo tên model) ngay tại thời
điểm document thay đổi, nên vẫn hoạt động đúng dù bạn không gọi
schema.plugin() thủ công.
// db.js — file khởi tạo kết nối, PHẢI require/gọi TRƯỚC khi các model được định nghĩa
const mongoose = require('mongoose');
const { createStorageUsageTracker } = require('mongoose-storage-usage');
mongoose.connect('mongodb://localhost:27017/my_db');
const storageUsage = createStorageUsageTracker(mongoose, {
autoApply: true, // <-- chỉ cần dòng này
fileFieldsConfig: {
File: ['file'], // model "File" có field "file" chứa metadata upload
},
});
module.exports = { mongoose, storageUsage };// models/File.js — không cần gọi schema.plugin() gì cả
const { mongoose } = require('../db');
const FileSchema = new mongoose.Schema({
id_app: { type: String, required: true },
file: { size: Number, originalname: String /* ... */ },
});
module.exports = mongoose.model('File', FileSchema);⚠️ Lưu ý thứ tự require quan trọng: createStorageUsageTracker(mongoose, { autoApply: true })
phải chạy trước khi bất kỳ file model nào được require. Cách an toàn nhất là
đặt đoạn khởi tạo này trong file kết nối DB (db.js/database.js), và luôn
require('./db') trước khi require('./models/...') ở entry point (app.js/server.js).
Cách 2 — tracker.applyToAllModels() (khi không kiểm soát được thứ tự require)
Nếu project đã có sẵn nhiều model được định nghĩa trước khi bạn tích hợp thư viện
(hoặc không chắc thứ tự require), gọi hàm này sau khi tất cả model đã được
require — nó sẽ tự quét những model có id_app mà chưa được gắn plugin:
require('./models/File');
require('./models/Order');
require('./models/Invoice');
// ... require hết các model
const storageUsage = createStorageUsageTracker(mongoose, {
fileFieldsConfig: { File: ['file'] },
});
const appliedModels = storageUsage.applyToAllModels();
console.log('Đã gắn plugin cho:', appliedModels); // ví dụ: ['File', 'Order', 'Invoice']Có thể gọi lại nhiều lần an toàn (model đã gắn rồi sẽ tự bỏ qua, không gắn trùng). Cũng dùng được để gắn plugin bổ sung cho model mới đăng ký sau này (ví dụ trong plugin hệ thống nạp module động).
Cách 3 — Gắn thủ công từng schema (kiểm soát chi tiết nhất)
Vẫn giữ được nếu bạn muốn tuỳ biến fileFields riêng theo từng model hoặc chỉ
muốn áp dụng cho một số model nhất định:
FileSchema.plugin(storageUsage.plugin, { fileFields: ['file'] });
OrderSchema.plugin(storageUsage.plugin);So sánh nhanh
| Cách | Khi nào dùng | Cần sửa từng model? |
|---|---|---|
| autoApply: true | Project mới, kiểm soát được thứ tự require | Không |
| applyToAllModels() | Project có sẵn, model định nghĩa rải rác nhiều nơi | Không |
| Gắn thủ công schema.plugin() | Chỉ muốn áp dụng cho 1 số model, cần tuỳ biến riêng | Có |
API
createStorageUsageTracker(mongooseInstance, options?)
Khởi tạo 1 tracker, gọi 1 lần cho mỗi mongoose instance/connection.
| Option | Mặc định | Mô tả |
|---|---|---|
| modelName | 'StorageUsage' | Tên model lưu kết quả thống kê |
| collectionName | 'storage_usages' | Tên collection lưu kết quả |
| connection | mongooseInstance.connection | Dùng khi project có nhiều connection (multi-db) |
| fileFieldsConfig | {} | { [modelName]: string[] } — field chứa metadata file, dùng cho scanAllModels() và plugin khi gắn global |
| autoApply | false | true = tự động gắn plugin cho mọi schema định nghĩa sau đó (xem phần "Áp dụng cho toàn bộ model") |
| appModel | — | Model quản lý tenant (vd "App"), _id trùng id_app. Xem phần "Tích hợp với model quản lý tenant" |
| appFields | {name:'name', address:'address', expireDate:'expire_date', status:'trang_thai'} | Map tên field thực tế trên appModel |
| appStatusLabels | {'0':'Dùng thử', '1':'Chính thức'} | Map giá trị status -> nhãn hiển thị |
| bulkOptions | xem bảng bên dưới | Tối ưu cho import/batch lớn — xem phần "Tối ưu cho import/batch lớn" |
| distributedLock | true | Job cron chỉ chạy ở đúng 1 instance khi có nhiều cluster/worker — xem phần "Cron job an toàn khi chạy nhiều cluster" |
| lockKey | 'scan_all_models' | Tên lock — các instance phải dùng chung giá trị này mới tranh chấp đúng |
| lockTtlMs | 600000 (10 phút) | Lock tự hết hạn sau khoảng thời gian này nếu instance giữ lock crash không kịp release |
Chi tiết bulkOptions:
| Option | Mặc định | Mô tả |
|---|---|---|
| flushIntervalMs | 300 | Gộp nhiều lần $inc liên tiếp trong khoảng thời gian này thành 1 lần ghi DB |
| maxQueueSize | 5000 | Van an toàn: ép flush ngay nếu số entry (id_app+model) đang chờ vượt ngưỡng |
| chunkSize | 500 | Số document xử lý mỗi khối trước khi nhường lại event loop |
| sampleThreshold | 2000 | insertMany với số lượng >= ngưỡng này sẽ ước lượng bằng lấy mẫu |
| sampleSize | 200 | Số document lấy mẫu để ước lượng |
| useWorkerThread | false | Offload tính size batch lớn sang worker_threads riêng |
| workerThreshold | 2000 | Batch >= ngưỡng này mới dùng worker (khi đã bật useWorkerThread) |
| workerPoolSize | 2 | Số worker thread tối đa trong pool |
Trả về object tracker gồm:
| Thuộc tính | Mô tả |
|---|---|
| StorageUsageModel | Model Mongoose lưu kết quả (dùng nếu cần query tuỳ biến) |
| plugin | Mongoose plugin, gắn vào schema: schema.plugin(tracker.plugin, { fileFields: [...] }) |
| applyToAllModels(connection?) | Gắn plugin cho các model đã compile mà chưa có plugin, trả về danh sách tên model đã gắn |
| scanAllModels() | Quét toàn bộ model có id_app, ghi đè kết quả mới nhất |
| scanModelStorage(model) | Quét 1 model cụ thể, trả về mảng {id_app, doc_count, storage_bytes} |
| getTotalUsageByApp(idApp) | Tổng dung lượng của 1 tenant, chi tiết theo từng model (kèm thông tin App nếu có cấu hình) |
| getUsageRanking(limit?, opts?) | Bảng xếp hạng tenant dùng nhiều dung lượng nhất. opts.includeZeroUsageApps = kèm cả app chưa có dữ liệu |
| searchApps(query, limit?) | Tìm App theo tên (cần cấu hình appModel) |
| listAllApps(limit?) | Lấy toàn bộ danh sách App (cần cấu hình appModel) |
| hasAppModel | true/false — có đang cấu hình appModel hay không |
| registerCronJob(cronExpr?, options?) | Đăng ký job quét định kỳ (cần cài node-cron) |
| flushPendingWrites() | Ghi ngay mọi delta đang chờ trong hàng đợi debounce xuống DB, không đợi hết cửa sổ thời gian |
| shutdown() | flushPendingWrites() + dừng worker thread pool (nếu có bật useWorkerThread) |
| hasDistributedLock | true/false — cơ chế lock cho cron có đang bật hay không |
| lockOwner | "hostname:pid" của process hiện tại, hữu ích khi debug xem instance nào đã chạy job |
| createExpressRouter() | Trả về Express router có sẵn các route API (cần cài express) |
| createDashboardRouter() | Trả về Express router phục vụ dashboard UI + API (cần cài express) |
tracker.plugin
schema.plugin(tracker.plugin, { fileFields: ['file'] });- Schema phải có field
id_app, nếu không plugin sẽ bỏ qua (log warning). fileFields: tên field chứa metadata file — hỗ trợ cả object đơn (file: {...}) lẫn mảng nhiều file (attachments: [{...}, {...}]).- Tự động bắt các thao tác:
save(),deleteOne()(document & query),findOneAndUpdate/findOneAndReplace(kể cả upsert),updateOne/updateMany,findOneAndDelete/findOneAndRemove,deleteMany,insertMany.
Dashboard quản lý dung lượng (UI có sẵn, không cần build)
Package đi kèm 1 dashboard HTML tĩnh (thuần JS, không React/Vue, không bước build) để xem và quản lý dung lượng trực quan: bảng xếp hạng tenant, thanh đo mức dùng theo ngưỡng cảnh báo tự đặt, xem chi tiết theo từng model, và nút quét lại thủ công.
Mount vào app Express
app.use('/storage-dashboard', storageUsage.createDashboardRouter());Truy cập http://localhost:3000/storage-dashboard/ là dùng được ngay — không
cần cấu hình gì thêm. createDashboardRouter() tự gộp cả API cần thiết
(/api/scan, /api/app/:id_app, /api/ranking) vào cùng router nên bạn
không cần gọi thêm createExpressRouter() song song (trừ khi bạn muốn
tách API ra domain/path khác cho việc khác).
⚠️ Dashboard không có xác thực — nên đặt sau middleware kiểm tra quyền admin của bạn trước khi mount, ví dụ:
app.use('/storage-dashboard', requireAdmin, storageUsage.createDashboardRouter());Tính năng
- Bảng xếp hạng tenant theo dung lượng đã dùng, kèm tổng số document.
- Tên, trạng thái ("Dùng thử"/"Chính thức"), ngày hết hạn hiển thị trực
tiếp trên mỗi dòng nếu có cấu hình
appModel(xem phần "Tích hợp với model quản lý tenant" ở trên). Ngày hết hạn tự tô vàng khi còn ≤7 ngày, đỏ khi đã hết hạn. Không cấu hìnhappModelthì dashboard vẫn chạy bình thường, chỉ hiển thịid_appthay vì tên. - Ngưỡng cảnh báo tự đặt (MB) — nhập vào ô "Ngưỡng cảnh báo", các thanh đo
sẽ chuyển màu xanh (an toàn) → vàng (gần ngưỡng, ≥60%) → đỏ (≥90% hoặc vượt
ngưỡng). Nếu chưa đặt ngưỡng, màu được tính theo thứ hạng tương đối so
với tenant dùng nhiều nhất. Ngưỡng được lưu ở
localStoragetrình duyệt để giữ lại giữa các lần mở dashboard. - Tìm kiếm theo tên hoặc
id_app— khi cóappModel, gõ tên công ty là lọc được ngay, không cần nhớid_app. - Hiển thị cả tenant chưa có dữ liệu dung lượng (mới tạo, chưa upload gì) với dung lượng 0 — giúp nhìn thấy toàn cảnh tất cả tenant trong hệ thống.
- Bấm vào 1 dòng để mở rộng xem chi tiết dung lượng theo từng model của tenant đó (lazy-load, chỉ gọi API khi mở), kèm địa chỉ nếu có.
- Nút "Quét lại" gọi
POST /api/scanđể chạy quét thủ công ngay lập tức, tự refresh bảng xếp hạng sau khi quét xong.
Xác thực cho dashboard (khi mount kèm middleware như isAdmin)
Nếu bạn bảo vệ route dashboard bằng middleware yêu cầu token (JWT, header tuỳ biến...), ví dụ:
app.use('/storage-dashboard', isAdmin, storageUsage.createDashboardRouter());...request tải TRANG dashboard (GET /storage-dashboard/) sẽ đi qua isAdmin bình thường —
nhưng các lệnh fetch() mà chính dashboard tự gọi bên trong (api/ranking, api/scan...) thì
không tự động có token đó, vì trình duyệt chỉ tự động gắn lại COOKIE cho các request cùng
origin, chứ không tự gắn header như Authorization hay token tuỳ biến. Kết quả: trang tải được
nhưng dữ liệu bên trong báo lỗi 401/403.
Giải pháp: dùng injectConfig để chuyển tiếp chính token đã giúp request tải trang đi qua
isAdmin thành công, nhúng vào trang để JS phía client tự gắn lại cho mọi lệnh gọi API sau đó:
app.use('/storage-dashboard', isAdmin, storageUsage.createDashboardRouter({
injectConfig: (req) => ({
headers: { Authorization: req.headers.authorization }, // chuyển tiếp NGUYÊN VẸN header đã có
}),
}));injectConfig(req) nhận req của chính request tải trang (đã đi qua isAdmin nên có đầy đủ
thông tin xác thực), trả về object gồm:
| Field | Mặc định | Mô tả |
|---|---|---|
| headers | {} | Header sẽ tự động gắn vào MỌI lệnh fetch nội bộ của dashboard |
| tokenHeader | 'Authorization' | Chỉ dùng khi KHÔNG truyền headers trực tiếp — tên header sẽ gắn token vào (xem cách 2 bên dưới) |
| tokenPrefix | 'Bearer ' | Tiền tố gắn trước token |
Tuỳ theo cách isAdmin của bạn lấy token, điều chỉnh injectConfig tương ứng:
// isAdmin đọc token từ cookie (đã có sẵn tên field, không cần header đặc biệt)
// -> KHÔNG cần injectConfig gì cả, browser tự động gửi lại cookie cho mọi fetch() cùng origin.
// isAdmin đọc token từ header tuỳ biến (không phải Authorization)
injectConfig: (req) => ({ headers: { 'x-access-token': req.headers['x-access-token'] } })
// isAdmin đọc token từ query string (?token=xxx) trên chính request gốc
injectConfig: (req) => ({ headers: { Authorization: 'Bearer ' + req.query.token } })
// isAdmin gắn sẵn token đã verify vào req.user.token sau khi decode JWT
injectConfig: (req) => ({ headers: { Authorization: 'Bearer ' + req.user.token } })Cách 2 — không sửa code server (khi không tiện chỉnh route mount): nếu isAdmin của bạn
CŨNG chấp nhận token qua query string, chỉ cần chia sẻ link kèm ?token=...:
https://your-app.com/storage-dashboard/?token=eyJhbGciOi...Dashboard tự đọc token từ URL, lưu vào sessionStorage (mất khi đóng tab, không phải
localStorage để giảm rủi ro lộ token lâu dài), và tự gắn thành header Authorization: Bearer
<token> cho mọi lệnh fetch nội bộ ở các lần tải lại tiếp theo trong cùng tab — không cần gõ lại
?token= mỗi lần refresh. Đổi tên query param/header/tiền tố qua injectConfig nếu cần:
injectConfig: () => ({ tokenParam: 'accessToken', tokenHeader: 'x-access-token', tokenPrefix: '' })Dùng dashboard độc lập (không qua Express)
Nếu không dùng Express, bạn vẫn có thể mở trực tiếp
node_modules/mongoose-storage-usage/dashboard/index.html bằng bất kỳ static
file server nào — chỉ cần tự expose 3 route API (scan, app/:id_app,
ranking) ở đường dẫn api/ tương đối so với file HTML đó (dashboard gọi
fetch('api/ranking'), không có / ở đầu, nên linh hoạt theo path mount).
Express router có sẵn (chỉ phần API, không kèm giao diện)
app.use('/api/storage-usage', storageUsage.createExpressRouter());| Method | Path | Mô tả |
|---|---|---|
| POST | /scan | Trigger quét thủ công (nên giới hạn quyền admin) |
| GET | /app/:id_app | Tổng dung lượng của 1 tenant (kèm thông tin App nếu có cấu hình appModel) |
| GET | /ranking?limit=20&includeZeroUsageApps=1 | Bảng xếp hạng dung lượng; includeZeroUsageApps=1 để kèm cả app chưa có dữ liệu |
| GET | /apps/search?q=acme&limit=20 | Tìm App theo tên (cần cấu hình appModel, trả về [] nếu không) |
Xử lý sự cố cài đặt
Lỗi liên quan đến bson (vd "Unsupported BSON version, bson types must be from bson X.x.x")
Package này không tự khai bson làm dependency riêng. Việc tính dung
lượng dùng thẳng mongoose.mongo.BSON — chính bản BSON mà driver MongoDB nội
bộ của mongoose bạn đang cài đặt sử dụng để giao tiếp với DB thật. Nhờ vậy
sẽ không bao giờ có 2 bản bson khác nhau cùng tồn tại trong
node_modules do package này gây ra, nên không thể xảy ra lỗi kiểu
"Unsupported BSON version" (lỗi này xảy ra khi 1 object BSON được tạo bởi
bản bson A nhưng bị bản bson B — không nhận diện được — cố serialize).
mongoose >= 6.0.0: dùng đường tính dung lượng chính xác tuyệt đối (qua chính BSON serializer của driver).mongoose 5.x:mongoose.mongo.BSONchưa tồn tại ở bản này, package tự động chuyển sang ước lượng dung lượng quaJSON.stringify— vẫn hoạt động bình thường, không throw lỗi, chỉ in ra 1 dòng cảnh báo console. Số liệu vẫn dùng tốt để so sánh tương đối dung lượng giữa các tenant, nhưng không chính xác tuyệt đối như BSON thật (BSON có overhead riêng theo từng kiểu dữ liệu mà JSON không phản ánh được, ví dụObjectIdchiếm 12 byte cố định nhưng khiJSON.stringifylại thành chuỗi hex 24 ký tự).- Nếu bạn thấy lỗi npm nhắc tới 1 version
bsoncụ thể không cài được (ví dụ[email protected]), rất có thể đó là con số không tồn tại trên npm registry (kiểm tra:npm view [email protected]sẽ trả 404) — npm chỉ đang hiển thị dải version (^4.2.2chẳng hạn) màmongooseyêu cầu, không phải version chính xác cần cài thủ công. Không cần khaibsontrongpackage.jsoncủa bạn nữa — package này không cần bạn làm vậy.
⚠️ Giới hạn cần biết
Model.bulkWrite()hoặc thao tác qua native MongoDB driver (không qua Mongoose Query API) sẽ bỏ qua hoàn toàn middleware — plugin sẽ không biết để cập nhật.$inclà atomic ở tầng MongoDB, nhưng plugin phải đọc "trạng thái cũ" bằng 1 query riêng trước khi ghi, nên trong tình huống hiếm có nhiều update chạy đồng thời trên cùng 1 document, số liệu có thể lệch nhẹ.- Vì 2 lý do trên, nên kết hợp
plugin(realtime, gần đúng) vớiregisterCronJob/scanAllModels()chạy định kỳ ban đêm như bước đối soát để đảm bảo số liệu luôn đúng tuyệt đối. - Quét toàn bộ collection lớn (
scanAllModels) có thể tốn thời gian; nên chạy vào giờ thấp điểm. Đã bậtallowDiskUse(true)để tránh lỗi memory limit 100MB khi group nhiềuid_app. - Với batch
insertManylớn (>=sampleThreshold, mặc định 2000), số liệu là ước lượng bằng lấy mẫu, không phải tính chính xác từng document — càng thêm lý do nên duy trìscanAllModels()định kỳ để đối soát về số chính xác. - Ghi dồn (
bulkOptions.flushIntervalMs) nghĩa là delta nằm trong bộ nhớ một khoảng ngắn trước khi ghi DB — nếu process bịkill -9(không phải SIGTERM/SIGINT) đúng lúc đó, phần delta chưa flush sẽ mất. Đây cũng là lý do nên có job quét định kỳ chạy song song để tự sửa lại số liệu.
Cron job an toàn khi chạy nhiều cluster/worker (PM2, nhiều server/container)
Nếu app của bạn chạy nhiều instance cùng lúc (PM2 cluster mode với nhiều worker, hoặc nhiều
server/container phía sau load balancer), mỗi instance đều tự gọi registerCronJob() khi khởi
động — nếu không kiểm soát, N instance sẽ có N cron cùng kích hoạt gần như đồng thời, dẫn đến
quét trùng lặp và tốn tài nguyên DB không cần thiết.
Mặc định từ bản này, cơ chế distributed lock đã tự động BẬT — không cần cấu hình gì thêm.
Cơ chế dùng chung 1 collection nhỏ trên chính MongoDB đang có (không cần Redis hay hạ tầng khác):
mỗi lượt cron kích hoạt, các instance cùng "giành" 1 document lock bằng thao tác atomic của
MongoDB — chỉ đúng 1 instance thắng và thực sự chạy scanAllModels(), các instance còn lại
tự động bỏ qua lượt đó, không cần bạn phân biệt "instance nào là master" theo bất kỳ cách nào
(không phụ thuộc NODE_APP_INSTANCE/pm_id của PM2 — hoạt động đúng cả khi chạy nhiều server
vật lý khác nhau, không riêng gì PM2 cluster trên 1 máy).
// KHÔNG cần thay đổi gì — cứ gọi registerCronJob() như cũ ở MỌI instance
storageUsage.registerCronJob('0 2 * * *');Cấu hình (tuỳ chọn)
const storageUsage = createStorageUsageTracker(mongoose, {
distributedLock: true, // mặc định true — đặt false nếu CHẮC CHẮN chỉ chạy 1 instance
lockKey: 'scan_all_models', // các instance PHẢI dùng chung giá trị này mới tranh chấp đúng lock
lockTtlMs: 10 * 60 * 1000, // 10 phút (mặc định) — nên đặt LỚN HƠN thời gian quét thực tế
});lockTtlMs: đóng vai trò an toàn — nếu instance đang giữ lock bị crash giữa chừng (không kịp release), lock tự hết hạn sau khoảng thời gian này để instance khác giành được ở lượt kế tiếp, tránh bị kẹt lock vĩnh viễn. Đặt quá ngắn so với thời gian quét thực tế có thể khiến 2 instance chạy chồng lấn nhau (lock hết hạn trong khi job đầu vẫn đang chạy); đặt quá dài thì sau crash phải chờ lâu hơn mới tự phục hồi. Job xong đều tựrelease()sớm, không cần đợi hết TTL trong điều kiện bình thường.lockKey: chỉ cần đổi nếu bạn chạy NHIỀU tracker khác nhau (nhiều connection/nhiều app) cùng trỏ vào 1 database và muốn cron của chúng không tranh chấp lẫn nhau.
Debug / theo dõi
console.log(storageUsage.lockOwner); // "hostname:pid" của process hiện tạistorageUsage.registerCronJob('0 2 * * *', {
onSkipped: (reason) => console.log('Bỏ qua lượt này:', reason), // instance khác đã chạy
onSuccess: (results) => console.log('Instance này vừa chạy job:', storageUsage.lockOwner),
});Về lỗi E11000 duplicate key ... storage_usage_locks
Nếu bạn thấy lỗi này xuất hiện trong log MongoDB, đây là hành vi bình thường của cơ chế
tranh chấp lock, không phải bug — document lock chỉ được tạo (upsert) đúng 1 lần duy nhất
trong suốt vòng đời cluster (lúc bootstrap, khi nhiều instance cùng khởi động lần đầu và cùng cố
tạo document lock). Sau lần đó, document tồn tại vĩnh viễn (không bị xoá khi release), nên các
lượt tranh chấp tiếp theo chỉ dùng update thông thường — không bao giờ sinh thêm lỗi
duplicate key nữa dù chạy bao nhiêu lượt cron. Nếu bạn thấy lỗi này lặp lại liên tục ở MỌI lượt
cron (không chỉ 1 lần khi mới deploy), hãy cập nhật lên bản mới nhất của package — các bản trước
1.7.1 xoá hẳn document khi release, khiến lỗi này lặp lại ở mỗi lượt tranh chấp.
⚠️ Lưu ý: cơ chế lock chỉ áp dụng cho cron job (registerCronJob). Gọi trực tiếp
scanAllModels() (ví dụ qua route POST /api/scan khi bấm nút "Quét lại" trên dashboard) KHÔNG
bị chặn bởi lock — đây là hành vi có chủ đích, vì đó là hành động thủ công của người dùng, không
nên bị âm thầm bỏ qua.
Publish package này lên npm (cho maintainer)
Nếu bạn muốn tự publish lại package (ví dụ đổi tên, thêm tính năng riêng):
cd mongoose-storage-usage
# 1. Đăng nhập npm (chỉ cần 1 lần trên máy)
npm login
# 2. Kiểm tra tên package chưa bị trùng
npm view mongoose-storage-usage version # phải trả về lỗi 404 nghĩa là tên còn trống
# 3. (Tuỳ chọn) sửa "name", "author", "repository" trong package.json
# Nếu tên bị trùng, đổi thành package có scope: "@ten-cua-ban/mongoose-storage-usage"
# 4. Kiểm tra danh sách file sẽ được đóng gói
npm pack --dry-run
# 5. Publish
npm publish # hoặc: npm publish --access public (nếu dùng scoped package)Từ lần sau, khi cập nhật code, tăng version rồi publish lại:
npm version patch # hoặc minor / major theo mức độ thay đổi
npm publishSau khi publish, cài vào bất kỳ project nào khác:
npm install mongoose-storage-usageLicense
MIT
