www.peterbe.com Open in urlscan Pro
2a0b:4d07:102::1  Public Scan

Submitted URL: http://www.peterbe.com/
Effective URL: https://www.peterbe.com/
Submission: On February 26 via api from US — Scanned from DE

Form analysis 0 forms found in the DOM

Text Content

PETERBE.COM

Peter Bengtsson's blog
 * Archive
 * About
 * Contact
 * Search


HOW TO AVOID A COUNT QUERY IN DJANGO IF YOU CAN


FEBRUARY 14, 2024
1 COMMENT DJANGO, PYTHON

Suppose you have a complex Django QuerySet query that is somewhat costly (in
other words slow). And suppose you want to return:

 1. The first N results
 2. A count of the total possible results

So your implementation might be something like this:


def get_results(queryset, fields, size):
    count = queryset.count()
    results = []
    for record in queryset.values(*fields)[:size]
        results.append(record)
    return {"count": count, "results": results}


That'll work. If there are 1,234 rows in your database table that match those
specific filters, what you might get back from this is:


>>> results = get_results(my_queryset, ("name", "age"), 5)
>>> results["count"]
1234
>>> len(results["results"])
5


Or, if the filters would only match 3 rows in your database table:


>>> results = get_results(my_queryset, ("name", "age"), 5)
>>> results["count"]
3
>>> len(results["results"])
3


Between your Python application and your database you'll see:

query 1: SELECT COUNT(*) FROM my_database WHERE ...
query 2: SELECT name, age FROM my_database WHERE ... LIMIT 5

The problem with this is that, in the latter case, you had to send two database
queries when all you needed was one.
If you knew it would only match a tiny amount of records, you could do this:


def get_results(queryset, fields, size):
-   count = queryset.count()
    results = []
    for record in queryset.values(*fields)[:size]:
        results.append(record)
+   count = len(results)
    return {"count": count, "results": results}


But that is wrong. The count would max out at whatever the size is.

The solution is to try to avoid the potentially unnecessary .count() query.


def get_results(queryset, fields, size):
    count = 0
    results = []
    for i, record in enumerate(queryset.values(*fields)[: size + 1]):
        if i == size:
            # Alas, there are more records than the pagination
            count = queryset.count()
            break
        count = i + 1
        results.append(record)
    return {"count": count, "results": results}


This way, you only incur one database query when there wasn't that much to find,
but if there was more than what the pagination called for, you have to incur
that extra database query.

Please post a comment if you have thoughts or questions


HOW TO RESTORE ALL UNSTAGED FILES IN WITH GIT


FEBRUARY 8, 2024
0 COMMENTS GITHUB, MACOSX, LINUX

tl;dr git restore -- .

I can't believe I didn't know this! Maybe, at one point, I did, but, since
forgotten.

You're in a Git repo and you have edited 4 files and run git status and see
this:


❯ git status
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   four.txt
    modified:   one.txt
    modified:   three.txt
    modified:   two.txt

no changes added to commit (use "git add" and/or "git commit -a")


Suppose you realize; "Oh no! I didn't mean to make those changes in three.txt"
You can restore that file by mentioning it by name:


❯ git restore three.txt

❯ git status
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   four.txt
    modified:   one.txt
    modified:   two.txt

no changes added to commit (use "git add" and/or "git commit -a")


Now, suppose you realize you want to all of those modified files. How do you
restore them all without mentioning each and every one by name. Simple:


❯ git status
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   four.txt
    modified:   one.txt
    modified:   two.txt

no changes added to commit (use "git add" and/or "git commit -a")

❯ git restore -- .

❯ git status
On branch main
nothing to commit, working tree clean


The "trick" is: git restore -- .

As far as I understand restore is the new word for checkout. You can equally run
git checkout -- . too.

Please post a comment if you have thoughts or questions


HOW SLOW IS NODE TO BROTLI DECOMPRESS A FILE COMPARED TO NOT HAVING TO
DECOMPRESS?


JANUARY 19, 2024
3 COMMENTS NODE, MACOSX, LINUX

tl;dr; Not very slow.

At work, we have some very large .json that get included in a Docker image. The
Node server then opens these files at runtime and displays certain data from
that. To make the Docker image not too large, we compress these .json files at
build-time. We compress the .json files with Brotli to make a .json.br file.
Then, in the Node server code, we read them in and decompress them at runtime.
It looks something like this:


export function readCompressedJsonFile(xpath) {
  return JSON.parse(brotliDecompressSync(fs.readFileSync(xpath)))
}


The advantage of compressing them first, at build time, which is GitHub Actions,
is that the Docker image becomes smaller which is advantageous when shipping
that image to a registry and asking Azure App Service to deploy it. But I was
wondering, is this a smart trade-off? In a sense, why compromise on runtime
(which faces users) to save time and resources at build-time, which is mostly
done away from the eyes of users? The question was; how much overhead is it to
have to decompress the files after its data has been read from disk to memory?


THE BENCHMARK

The files I test with are as follows:


❯ ls -lh pageinfo*
-rw-r--r--  1 peterbe  staff   2.5M Jan 19 08:48 pageinfo-en-ja-es.json
-rw-r--r--  1 peterbe  staff   293K Jan 19 08:48 pageinfo-en-ja-es.json.br
-rw-r--r--  1 peterbe  staff   805K Jan 19 08:48 pageinfo-en.json
-rw-r--r--  1 peterbe  staff   100K Jan 19 08:48 pageinfo-en.json.br


There are 2 groups:

 1. Only English (en)
 2. 3 times larger because it has English, Japanese, and Spanish

And for each file, you can see the effect of having compressed them with Brotli.

 1. The smaller JSON file compresses 8x
 2. The larger JSON file compresses 9x

Here's the benchmark code:


import fs from "fs";
import { brotliDecompressSync } from "zlib";
import { Bench } from "tinybench";

const JSON_FILE = "pageinfo-en.json";
const BROTLI_JSON_FILE = "pageinfo-en.json.br";
const LARGE_JSON_FILE = "pageinfo-en-ja-es.json";
const BROTLI_LARGE_JSON_FILE = "pageinfo-en-ja-es.json.br";

function f1() {
  const data = fs.readFileSync(JSON_FILE, "utf8");
  return Object.keys(JSON.parse(data)).length;
}

function f2() {
  const data = brotliDecompressSync(fs.readFileSync(BROTLI_JSON_FILE));
  return Object.keys(JSON.parse(data)).length;
}

function f3() {
  const data = fs.readFileSync(LARGE_JSON_FILE, "utf8");
  return Object.keys(JSON.parse(data)).length;
}

function f4() {
  const data = brotliDecompressSync(fs.readFileSync(BROTLI_LARGE_JSON_FILE));
  return Object.keys(JSON.parse(data)).length;
}

console.assert(f1() === 2633);
console.assert(f2() === 2633);
console.assert(f3() === 7767);
console.assert(f4() === 7767);

const bench = new Bench({ time: 100 });
bench.add("f1", f1).add("f2", f2).add("f3", f3).add("f4", f4);
await bench.warmup(); // make results more reliable, ref: https://github.com/tinylibs/tinybench/pull/50
await bench.run();

console.table(bench.table());


Here's the output from tinybench:

┌─────────┬───────────┬─────────┬────────────────────┬──────────┬─────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ns)  │  Margin  │ Samples │
├─────────┼───────────┼─────────┼────────────────────┼──────────┼─────────┤
│    0    │   'f1'    │  '179'  │  5563384.55941942  │ '±6.23%' │   18    │
│    1    │   'f2'    │  '150'  │ 6627033.621072769  │ '±7.56%' │   16    │
│    2    │   'f3'    │  '50'   │ 19906517.219543457 │ '±3.61%' │   10    │
│    3    │   'f4'    │  '44'   │ 22339166.87965393  │ '±3.43%' │   10    │
└─────────┴───────────┴─────────┴────────────────────┴──────────┴─────────┘

Note, this benchmark is done on my 2019 Intel MacBook Pro. This disk is not what
we get from the Apline Docker image (running inside Azure App Service). To test
that would be a different story. But, at least we can test it in Docker locally.

I created a Dockerfile that contains...

ARG NODE_VERSION=20.10.0

FROM node:${NODE_VERSION}-alpine

and run the same benchmark in there by running docker composite up --build. The
results are:

┌─────────┬───────────┬─────────┬────────────────────┬──────────┬─────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ns)  │  Margin  │ Samples │
├─────────┼───────────┼─────────┼────────────────────┼──────────┼─────────┤
│    0    │   'f1'    │  '151'  │ 6602581.124978315  │ '±1.98%' │   16    │
│    1    │   'f2'    │  '112'  │  8890548.4166656   │ '±7.42%' │   12    │
│    2    │   'f3'    │  '44'   │ 22561206.40002191  │ '±1.95%' │   10    │
│    3    │   'f4'    │  '37'   │ 26979896.599974018 │ '±1.07%' │   10    │
└─────────┴───────────┴─────────┴────────────────────┴──────────┴─────────┘


ANALYSIS/CONCLUSION

First, focussing on the smaller file: Processing the .json is 25% faster than
the .json.br file

Then, the larger file: Processing the .json is 16% faster than the .json.br file

So that's what we're paying for a smaller Docker image. Depending on the size of
the .json file, your app runs ~20% slower at this operation. But remember, as a
file on disk (in the Docker image), it's ~8x smaller.

I think, in conclusion: It's a small price to pay. It's worth doing. Your
context depends.
Keep in mind the numbers there to process that 300KB pageinfo-en-ja-es.json.br
file, it was able to do that 37 times in one second. That means it took 27
milliseconds to process that file!


THE CAVEATS

To repeat, what was mentioned above: This was run in my Intel MacBook Pro. It's
likely to behave differently in a real Docker image running inside Azure.

The thing that I wonder the most about is arguably something that actually
doesn't matter. 🙃
When you ask it to read in a .json.br file, there's less data to ask from the
disk into memory. That's a win. You lose on CPU work but gain on disk I/O. But
only the end net result matters so in a sense that's just an "implementation
detail".

Admittedly, I don't know if the macOS or the Linux kernel does things with
caching the layer between the physical disk and RAM for these files. The
benchmark effectively asks "Hey, hard disk, please send me a file called ..."
and this could be cached in some layer beyond my knowledge/comprehension. In a
real production server, this only happens once because once the whole file is
read, decompressed, and parsed, it won't be asked for again. Like, ever. But in
a benchmark, perhaps the very first ask of the file is slower and all the other
runs are unrealistically faster.

Feel free to clone https://github.com/peterbe/reading-json-files and mess around
to run your own tests. Perhaps see what effect async can have. Or perhaps try it
with Bun and it's file system API.

Please post a comment if you have thoughts or questions


SEARCH HIDDEN DIRECTORIES WITH RIPGREP, BY DEFAULT


DECEMBER 30, 2023
0 COMMENTS MACOSX, LINUX

Do you use rg (ripgrep) all the time on the command line? Yes, so do I. An
annoying problem with it is that, by default, it does not search hidden
directories.

"A file or directory is considered hidden if its base name starts with a dot
character (.)."

One such directory, that is very important in my git/GitHub-based projects
(which is all of mine by the way) is the .github directory. So I cd into a
directory and it finds nothing:


cd ~/dev/remix-peterbecom
rg actions/setup-node
# Empty! I.e. no results


It doesn't find anything because the file .github/workflows/test.yml is part of
a hidden directory.

The quick solution to this is to use --hidden:


❯ rg --hidden actions/setup-node
.github/workflows/test.yml
20:        uses: actions/setup-node@v4


I find it very rare that I would not want to search hidden directories. So I
added this to my ~/.zshrc file:


alias rg='rg --hidden'


Now, this happens:


❯ rg actions/setup-node
.github/workflows/test.yml
20:        uses: actions/setup-node@v4


With that being set, it's actually possible to "undo" the behavior. You can use
--no-hidden


❯ rg --no-hidden actions/setup-node



And that can useful if there is a hidden directory that is not git ignored yet.
For example .download-cache/.

Please post a comment if you have thoughts or questions


FNM IS MUCH FASTER THAN NVM.


DECEMBER 28, 2023
1 COMMENT NODE, MACOSX

I used nvm so that when I cd into a different repo, it would automatically load
the appropriate version of node (and npm). Simply by doing cd
~/dev/remix-peterbecom, for example, it would make the node executable to become
whatever the value of the optional file ~/dev/remix-peterbecom/.nvmrc's content.
For example v18.19.0.
And nvm helps you install and get your hands on various versions of node to be
able to switch between. Much more fine-tuned than brew install node20.

The problem with all of this is that it's horribly slow. Opening a new terminal
is annoyingly slow because that triggers the entering of a directory and nvm
slowly does what it does.

The solution is to ditch it and go for fnm instead. Please, if you're an nvm
user, do consider making this same jump in 2024.


INSTALLATION

Running curl -fsSL https://fnm.vercel.app/install | bash basically does some
brew install and figuring out what shell you have and editing your shell config.
By default, it put:


export PATH="/Users/peterbe/Library/Application Support/fnm:$PATH"
eval "`fnm env`"


...into my .zshrc file. But, I later learned you need to edit the last line to:


-eval "`fnm env`"
+eval "$(fnm env --use-on-cd)"


so that it automatically activates immediately after you've cd'ed into a
directory.
If you had direnv to do this, get rid of that. fmn does not need direnv.

Now, create a fresh new terminal and it should be set up, including tab
completion. You can test it by typing fnm[TAB]. You'll see:


❯ fnm
alias                   -- Alias a version to a common name
completions             -- Print shell completions to stdout
current                 -- Print the current Node.js version
default                 -- Set a version as the default version
env                     -- Print and set up required environment variables for fnm
exec                    -- Run a command within fnm context
help                    -- Print this message or the help of the given subcommand(s)
install                 -- Install a new Node.js version
list         ls         -- List all locally installed Node.js versions
list-remote  ls-remote  -- List all remote Node.js versions
unalias                 -- Remove an alias definition
uninstall               -- Uninstall a Node.js version
use                     -- Change Node.js version



USAGE

If you had .nvmrc files sprinkled about from before, fnm will read those. If you
cd into a directory, that contains .nvmrc, whose version fnm hasn't installed,
yet, you get this:


❯ cd ~/dev/GROCER/groce/
Can't find an installed Node version matching v16.14.2.
Do you want to install it? answer [y/N]:


Neat!

But if you want to set it up from scratch, go into your directory of choice,
type:


fnm ls-remote


...to see what versions of node you can install. Suppose you want v20.10.0 in
the current directory do these two commands:


fnm install v20.10.0
echo v20.10.0 > .node-version


That's it!


NOTES

 * I prefer that .node-version convention so I've been going around doing mv
   .nvmrc .node-version in various projects

 * fnm ls is handy to see which ones you've installed already

 * Suppose you want to temporarily use a specific version, simply type fnm use
   v16.20.2 for example

 * I heard good things about volta too but got a bit nervous when I found out it
   gets involved in installing packages and not just versions of node.

 * fnm does not concern itself with upgrading your node versions. To get the
   latest version of node v21.x, it's up to you to check fnm ls-remote and
   compare that with the output of node --version.

Please post a comment if you have thoughts or questions


COMPARING DIFFERENT EFFORTS WITH WEBP IN SHARP


OCTOBER 5, 2023
0 COMMENTS NODE, JAVASCRIPT

When you, in a Node program, use sharp to convert an image buffer to a WebP
buffer, you have an option of effort. The higher the number the longer it takes
but the image it produces is smaller on disk.

I wanted to put some realistic numbers for this, so I wrote a benchmark, run on
my Intel MacbookPro.


THE BENCHMARK

It looks like this:


async function e6() {
  return await f("screenshot-1000.png", 6);
}
async function e5() {
  return await f("screenshot-1000.png", 5);
}
async function e4() {
  return await f("screenshot-1000.png", 4);
}
async function e3() {
  return await f("screenshot-1000.png", 3);
}
async function e2() {
  return await f("screenshot-1000.png", 2);
}
async function e1() {
  return await f("screenshot-1000.png", 1);
}
async function e0() {
  return await f("screenshot-1000.png", 0);
}

async function f(fp, effort) {
  const originalBuffer = await fs.readFile(fp);
  const image = sharp(originalBuffer);
  const { width } = await image.metadata();
  const buffer = await image.webp({ effort }).toBuffer();
  return [buffer.length, width, { effort }];
}


Then, I ran each function in serial and measured how long it took. Then, do that
whole thing 15 times. So, in total, each function is executed 15 times. The
numbers are collected and the median (P50) is reported.


A 2000X2000 PIXEL PNG IMAGE

1. e0: 191ms                   235KB
2. e1: 340.5ms                 208KB
3. e2: 369ms                   198KB
4. e3: 485.5ms                 193KB
5. e4: 587ms                   177KB
6. e5: 695.5ms                 177KB
7. e6: 4811.5ms                142KB

What it means is that if you use {effort: 6} the conversion of a 2000x2000 PNG
took 4.8 seconds but the resulting WebP buffer became 142KB instead of the least
effort which made it 235 KB.



This graph demonstrates how the (blue) time goes up the more effort you put in.
And how the final size (red) goes down the more effort you put in.


A 1000X1000 PIXEL PNG IMAGE

1. e0: 54ms                    70KB
2. e1: 60ms                    66KB
3. e2: 65ms                    61KB
4. e3: 96ms                    59KB
5. e4: 169ms                   53KB
6. e5: 193ms                   53KB
7. e6: 1466ms                  51KB


A 500X500 PIXEL PNG IMAGE

1. e0: 24ms                    23KB
2. e1: 26ms                    21KB
3. e2: 28ms                    20KB
4. e3: 37ms                    19KB
5. e4: 57ms                    18KB
6. e5: 66ms                    18KB
7. e6: 556ms                   18KB


CONCLUSION

Up to you but clearly, {effort: 6} is to be avoided if you're worried about it
taking a huge amount of time to make the conversion.

Perhaps the takeaway is; that if you run these operations in the build step such
that you don't have to ever do it again, it's worth the maximum effort. Beyond
that, find a sweet spot for your particular environment and challenge.

Please post a comment if you have thoughts or questions

Previous page
Next page
 * Home
 * Archive
 * About
 * Contact
 * Search

© peterbe.com 2003 - 2024

Check out my side project: That's Groce!