feat(resolution): bridge Sidekiq Worker.perform_async to #perform
Sidekiq decouples a job's enqueue site from the worker's perform method, linked by the worker class NAME: DestroyUserWorker.perform_async(id) has no static edge to DestroyUserWorker#perform (usually in app/workers/, away from the controller/model that enqueues it). sidekiqDispatchEdges bridges each Worker.perform_async/_in/_at(...) site -> that worker's instance perform. Name-keyed, like Celery: the receiver class must be a Sidekiq worker, gated by reading `include Sidekiq::Job|Worker` from the class body (the mixin is an external gem module that forms no resolvable edge). ActiveJob's perform_later/ _now is a different shape and deliberately not matched. Namespace disambiguation was the n>1 validation payoff: loomio's flat workers hid a collision bug that forem exposed (four SendEmailNotificationWorker classes across modules; simple-name resolution mis-targeted 7/143 edges to the wrong namespace). Fixed by resolving a namespaced receiver via exact qualified-name lookup first, falling back to the simple name only for a unique worker — an ambiguous unqualified collision bails (precision over recall). Surfaces as `dynamic: sidekiq dispatch` via the generic synth-edge fallback. Validated 100% precision on two grep-confirmed repos: loomio (medium, Sidekiq::Worker, 47 edges) and forem (large, both include aliases — 131 Sidekiq::Job + 11 Sidekiq::Worker, 142 edges, 0 worker/source false positives, 0 namespace mismatch); 0 on the jekyll control. Node-stable (pure edge synth). Suite 1621 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d1381e11f6
commit
2c522c6254
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Sidekiq job-dispatch bridge (Ruby).
|
||||
*
|
||||
* Sidekiq decouples a job enqueue from the worker's `perform`, linked by the WORKER CLASS
|
||||
* NAME: `DestroyUserWorker.perform_async(id)` has no static edge to `DestroyUserWorker#perform`
|
||||
* (usually a different file). This bridges each `Worker.perform_async`/`.perform_in`/`.perform_at`
|
||||
* site to that worker's instance `perform`, gated on the class including `Sidekiq::Job`/`Worker`.
|
||||
* Covers both include aliases, the scheduled forms, namespace disambiguation (two `NotifyWorker`s
|
||||
* in different modules resolve to the right one by qualified name), and the precision boundary: a
|
||||
* non-worker class with a `perform`, and an ActiveJob `perform_later`, both produce no edge.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
describe('sidekiq-dispatch synthesizer', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidekiq-dispatch-')); });
|
||||
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
const write = (rel: string, body: string) => {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, body);
|
||||
};
|
||||
|
||||
it('bridges perform_async/_in to #perform, disambiguates namespaces, ignores non-workers and ActiveJob', async () => {
|
||||
write('app/workers/destroy_user_worker.rb', `class DestroyUserWorker
|
||||
include Sidekiq::Worker
|
||||
def perform(user_id)
|
||||
User.find(user_id).destroy!
|
||||
end
|
||||
end
|
||||
`);
|
||||
// Modern Sidekiq::Job alias + the scheduled form.
|
||||
write('app/workers/send_email_worker.rb', `class SendEmailWorker
|
||||
include Sidekiq::Job
|
||||
def perform(addr)
|
||||
end
|
||||
end
|
||||
`);
|
||||
// Namespace collision: two NotifyWorkers, same simple name, different modules.
|
||||
write('app/workers/comments/notify_worker.rb', `module Comments
|
||||
class NotifyWorker
|
||||
include Sidekiq::Job
|
||||
def perform(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
`);
|
||||
write('app/workers/articles/notify_worker.rb', `module Articles
|
||||
class NotifyWorker
|
||||
include Sidekiq::Job
|
||||
def perform(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
`);
|
||||
// A non-worker class that happens to have a `perform` method — never a target.
|
||||
write('app/services/report.rb', `class Report
|
||||
def perform(x)
|
||||
end
|
||||
end
|
||||
`);
|
||||
// An ActiveJob — dispatched via perform_later, a different shape, not matched.
|
||||
write('app/jobs/cleanup_job.rb', `class CleanupJob < ApplicationJob
|
||||
def perform
|
||||
end
|
||||
end
|
||||
`);
|
||||
write('app/services/user_service.rb', `class UserService
|
||||
def deactivate(user)
|
||||
DestroyUserWorker.perform_async(user.id)
|
||||
SendEmailWorker.perform_in(5, user.email)
|
||||
Comments::NotifyWorker.perform_async(1)
|
||||
Articles::NotifyWorker.perform_async(2)
|
||||
Report.perform_async(3)
|
||||
CleanupJob.perform_later
|
||||
end
|
||||
end
|
||||
`);
|
||||
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
|
||||
const edges = db
|
||||
.prepare(
|
||||
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
|
||||
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
|
||||
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`
|
||||
)
|
||||
.all();
|
||||
|
||||
// Four enqueues bridge: both aliases, perform_async + perform_in, two namespaced.
|
||||
expect(edges.map((r: any) => r.via).sort()).toEqual([
|
||||
'Articles::NotifyWorker', 'Comments::NotifyWorker', 'DestroyUserWorker', 'SendEmailWorker',
|
||||
]);
|
||||
expect(edges.every((r: any) => r.target === 'perform' && r.source === 'deactivate')).toBe(true);
|
||||
// Namespace disambiguation: each NotifyWorker hits its OWN module's file, not the other.
|
||||
expect(edges.find((r: any) => r.via === 'Comments::NotifyWorker').tf).toMatch(/comments[\\/]notify_worker\.rb$/);
|
||||
expect(edges.find((r: any) => r.via === 'Articles::NotifyWorker').tf).toMatch(/articles[\\/]notify_worker\.rb$/);
|
||||
// PRECISION: a non-worker `perform`, and ActiveJob `perform_later`, contribute nothing.
|
||||
expect(edges.some((r: any) => r.via === 'Report')).toBe(false);
|
||||
expect(edges.some((r: any) => /Cleanup/.test(r.via))).toBe(false);
|
||||
|
||||
cg.close?.();
|
||||
});
|
||||
|
||||
it('produces no edges in a Ruby project with no Sidekiq (clean control)', async () => {
|
||||
write('lib/calc.rb', `class Calc
|
||||
def add(a, b)
|
||||
a + b
|
||||
end
|
||||
end
|
||||
`);
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
const count = db
|
||||
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`)
|
||||
.get();
|
||||
expect(count.c).toBe(0);
|
||||
cg.close?.();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user